Compare commits
10 Commits
5acaeba2bb
...
186bf25cb1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
186bf25cb1 | ||
|
|
38b8d0da85 | ||
|
|
889903f0fa | ||
|
|
3bace071bf | ||
|
|
f378f00bfb | ||
|
|
5e6749fbf3 | ||
|
|
15aa6884a2 | ||
|
|
dbf73e90fa | ||
|
|
bbf02c3224 | ||
|
|
ee002e7fc5 |
@@ -3470,6 +3470,19 @@ def run_conversation(
|
||||
f"⚠️ Tool guardrail halted {decision.tool_name}: {decision.code}"
|
||||
)
|
||||
messages.append({"role": "assistant", "content": final_response})
|
||||
# Emit the halt message to the client so it's not
|
||||
# indistinguishable from a crash. The stream display
|
||||
# was flushed (callback(None)) before tool execution,
|
||||
# but the callback is still alive — fire the text
|
||||
# through it so SSE/TUI clients see the explanation.
|
||||
if final_response:
|
||||
agent._safe_print(f"\n{final_response}\n")
|
||||
if agent.stream_delta_callback:
|
||||
try:
|
||||
agent.stream_delta_callback(final_response)
|
||||
agent.stream_delta_callback(None)
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
# Reset per-turn retry counters after successful tool
|
||||
|
||||
@@ -35,6 +35,7 @@ import re
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
@@ -337,10 +338,12 @@ class ResponseStore:
|
||||
db_path = str(get_hermes_home() / "response_store.db")
|
||||
except Exception:
|
||||
db_path = ":memory:"
|
||||
self._db_path: Optional[str] = db_path if db_path != ":memory:" else None
|
||||
try:
|
||||
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||
except Exception:
|
||||
self._conn = sqlite3.connect(":memory:", check_same_thread=False)
|
||||
self._db_path = None
|
||||
# Use shared WAL-fallback helper so response_store.db degrades
|
||||
# gracefully on NFS/SMB/FUSE-mounted HERMES_HOME (same filesystem
|
||||
# issue addressed for state.db/kanban.db — see
|
||||
@@ -361,6 +364,31 @@ class ResponseStore:
|
||||
)"""
|
||||
)
|
||||
self._conn.commit()
|
||||
# response_store.db contains conversation history (tool payloads,
|
||||
# prompts, results). Tighten to owner-only after creation so other
|
||||
# local users on a shared box can't read it. Run once at __init__
|
||||
# rather than after every commit — chmod-on-every-write is wasted
|
||||
# syscalls on a hot path.
|
||||
self._tighten_file_permissions()
|
||||
|
||||
def _tighten_file_permissions(self) -> None:
|
||||
"""Force owner-only permissions on the DB and SQLite sidecars."""
|
||||
if not self._db_path:
|
||||
return
|
||||
for candidate in (
|
||||
Path(self._db_path),
|
||||
Path(f"{self._db_path}-wal"),
|
||||
Path(f"{self._db_path}-shm"),
|
||||
):
|
||||
try:
|
||||
if candidate.exists():
|
||||
candidate.chmod(0o600)
|
||||
except OSError:
|
||||
logger.debug(
|
||||
"Failed to restrict response store permissions for %s",
|
||||
candidate,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def get(self, response_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Retrieve a stored response by ID (updates access time for LRU)."""
|
||||
|
||||
@@ -3289,11 +3289,6 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
self._record_webhook_anomaly(remote_ip, "400")
|
||||
return web.json_response({"code": 400, "msg": "invalid json"}, status=400)
|
||||
|
||||
# URL verification challenge — respond before other checks so that Feishu's
|
||||
# subscription setup works even before encrypt_key is wired.
|
||||
if payload.get("type") == "url_verification":
|
||||
return web.json_response({"challenge": payload.get("challenge", "")})
|
||||
|
||||
# Verification token check — second layer of defence beyond signature (matches openclaw).
|
||||
if self._verification_token:
|
||||
header = payload.get("header") or {}
|
||||
@@ -3303,6 +3298,13 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
self._record_webhook_anomaly(remote_ip, "401-token")
|
||||
return web.Response(status=401, text="Invalid verification token")
|
||||
|
||||
# URL verification challenge — Feishu includes the verification token in
|
||||
# challenge requests. Validate the token (above) before reflecting the
|
||||
# challenge so an unauthenticated remote request cannot prove endpoint
|
||||
# control by getting attacker-supplied challenge data echoed back.
|
||||
if payload.get("type") == "url_verification":
|
||||
return web.json_response({"challenge": payload.get("challenge", "")})
|
||||
|
||||
# Timing-safe signature verification (only enforced when encrypt_key is set).
|
||||
if self._encrypt_key and not self._is_webhook_signature_valid(request.headers, body_bytes):
|
||||
logger.warning("[Feishu] Webhook rejected: invalid signature from %s", remote_ip)
|
||||
|
||||
@@ -27,6 +27,8 @@ Security:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
@@ -377,9 +379,21 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
logger.error("[webhook] Failed to read body: %s", e)
|
||||
return web.json_response({"error": "Bad request"}, status=400)
|
||||
|
||||
# Validate HMAC signature FIRST (skip for INSECURE_NO_AUTH testing mode)
|
||||
# Validate HMAC signature FIRST (skip only for the explicit local-test
|
||||
# INSECURE_NO_AUTH mode). Missing/empty secrets must fail closed here,
|
||||
# not only during connect(), so direct handler reuse cannot turn a
|
||||
# network webhook route into an unauthenticated agent-dispatch surface.
|
||||
secret = route_config.get("secret", self._global_secret)
|
||||
if secret and secret != _INSECURE_NO_AUTH:
|
||||
if not secret:
|
||||
logger.error(
|
||||
"[webhook] Route %s has no HMAC secret; refusing request",
|
||||
route_name,
|
||||
)
|
||||
return web.json_response(
|
||||
{"error": "Webhook route is missing an HMAC secret"},
|
||||
status=403,
|
||||
)
|
||||
if secret != _INSECURE_NO_AUTH:
|
||||
if not self._validate_signature(request, raw_body, secret):
|
||||
logger.warning(
|
||||
"[webhook] Invalid signature for route %s", route_name
|
||||
@@ -419,6 +433,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
request.headers.get("X-GitHub-Event", "")
|
||||
or request.headers.get("X-GitLab-Event", "")
|
||||
or payload.get("event_type", "")
|
||||
or payload.get("type", "")
|
||||
or "unknown"
|
||||
)
|
||||
allowed_events = route_config.get("events", [])
|
||||
@@ -471,7 +486,10 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
# Build a unique delivery ID
|
||||
delivery_id = request.headers.get(
|
||||
"X-GitHub-Delivery",
|
||||
request.headers.get("X-Request-ID", str(int(time.time() * 1000))),
|
||||
request.headers.get(
|
||||
"svix-id",
|
||||
request.headers.get("X-Request-ID", str(int(time.time() * 1000))),
|
||||
),
|
||||
)
|
||||
|
||||
# ── Idempotency ─────────────────────────────────────────
|
||||
@@ -616,7 +634,32 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
def _validate_signature(
|
||||
self, request: "web.Request", body: bytes, secret: str
|
||||
) -> bool:
|
||||
"""Validate webhook signature (GitHub, GitLab, generic HMAC-SHA256)."""
|
||||
"""Validate webhook signature (GitHub, GitLab, Svix, generic HMAC-SHA256)."""
|
||||
def _header(name: str) -> str:
|
||||
return (
|
||||
request.headers.get(name, "")
|
||||
or request.headers.get(name.lower(), "")
|
||||
or request.headers.get(name.upper(), "")
|
||||
)
|
||||
|
||||
# Svix / AgentMail:
|
||||
# svix-id: msg_...
|
||||
# svix-timestamp: unix seconds
|
||||
# svix-signature: v1,<base64-hmac> [v1,<base64-hmac> ...]
|
||||
# Signed content is: "{id}.{timestamp}.{raw_body}". Svix secrets
|
||||
# usually start with "whsec_" and the remainder is base64-encoded.
|
||||
svix_id = _header("svix-id")
|
||||
svix_timestamp = _header("svix-timestamp")
|
||||
svix_signature = _header("svix-signature")
|
||||
if svix_id or svix_timestamp or svix_signature:
|
||||
return self._validate_svix_signature(
|
||||
body=body,
|
||||
secret=secret,
|
||||
msg_id=svix_id,
|
||||
timestamp=svix_timestamp,
|
||||
signature_header=svix_signature,
|
||||
)
|
||||
|
||||
# GitHub: X-Hub-Signature-256 = sha256=<hex>
|
||||
gh_sig = request.headers.get("X-Hub-Signature-256", "")
|
||||
if gh_sig:
|
||||
@@ -644,6 +687,56 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return False
|
||||
|
||||
def _validate_svix_signature(
|
||||
self,
|
||||
body: bytes,
|
||||
secret: str,
|
||||
msg_id: str,
|
||||
timestamp: str,
|
||||
signature_header: str,
|
||||
tolerance_seconds: int = 300,
|
||||
) -> bool:
|
||||
"""Validate Svix-compatible signatures used by AgentMail webhooks."""
|
||||
if not (msg_id and timestamp and signature_header and secret):
|
||||
return False
|
||||
|
||||
try:
|
||||
ts = int(timestamp)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if abs(int(time.time()) - ts) > tolerance_seconds:
|
||||
logger.warning("[webhook] Svix signature timestamp outside replay window")
|
||||
return False
|
||||
|
||||
if secret.startswith("whsec_"):
|
||||
encoded_secret = secret.removeprefix("whsec_")
|
||||
try:
|
||||
key = base64.b64decode(encoded_secret, validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
logger.debug("[webhook] Invalid whsec_ Svix signing secret")
|
||||
return False
|
||||
else:
|
||||
# Be permissive for providers that document Svix-style headers but
|
||||
# hand out raw shared secrets rather than whsec_ base64 secrets.
|
||||
logger.debug("[webhook] Validating Svix-style signature with raw secret")
|
||||
key = secret.encode()
|
||||
|
||||
signed_content = msg_id.encode() + b"." + timestamp.encode() + b"." + body
|
||||
expected = base64.b64encode(
|
||||
hmac.new(key, signed_content, hashlib.sha256).digest()
|
||||
).decode()
|
||||
|
||||
# Svix can send multiple signatures separated by spaces during secret
|
||||
# rotation. Each entry is formatted as "vN,<base64>".
|
||||
for part in signature_header.split():
|
||||
try:
|
||||
version, signature = part.split(",", 1)
|
||||
except ValueError:
|
||||
continue
|
||||
if version == "v1" and hmac.compare_digest(signature, expected):
|
||||
return True
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Prompt rendering
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -119,7 +119,6 @@ _PUBLIC_API_PATHS: frozenset = frozenset({
|
||||
"/api/model/info",
|
||||
"/api/dashboard/themes",
|
||||
"/api/dashboard/plugins",
|
||||
"/api/dashboard/plugins/rescan",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ hot-reloaded by the webhook adapter without a gateway restart.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
@@ -23,6 +25,7 @@ from hermes_cli.config import cfg_get
|
||||
|
||||
|
||||
_SUBSCRIPTIONS_FILENAME = "webhook_subscriptions.json"
|
||||
_SUBSCRIPTIONS_FILE_MODE = 0o600
|
||||
|
||||
|
||||
def _hermes_home() -> Path:
|
||||
@@ -48,12 +51,33 @@ def _load_subscriptions() -> Dict[str, dict]:
|
||||
def _save_subscriptions(subs: Dict[str, dict]) -> None:
|
||||
path = _subscriptions_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(".tmp")
|
||||
tmp_path.write_text(
|
||||
json.dumps(subs, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
# webhook_subscriptions.json contains per-route HMAC secrets — write
|
||||
# via tempfile + chmod 0o600 before the atomic rename so a permissive
|
||||
# umask cannot leave the secrets readable to other local users in the
|
||||
# window between create and rename.
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
dir=path.parent,
|
||||
text=True,
|
||||
)
|
||||
atomic_replace(tmp_path, path)
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(subs, fh, indent=2, ensure_ascii=False)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.chmod(tmp_path, _SUBSCRIPTIONS_FILE_MODE)
|
||||
atomic_replace(tmp_path, path)
|
||||
# Re-assert after rename in case the destination existed with a
|
||||
# broader mode and atomic_replace preserved it.
|
||||
os.chmod(path, _SUBSCRIPTIONS_FILE_MODE)
|
||||
except Exception:
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _get_webhook_config() -> dict:
|
||||
|
||||
@@ -72,6 +72,7 @@ AUTHOR_MAP = {
|
||||
"70629228+shaun0927@users.noreply.github.com": "shaun0927",
|
||||
"98262967+Bihruze@users.noreply.github.com": "Bihruze",
|
||||
"189280367+Lempkey@users.noreply.github.com": "Lempkey",
|
||||
"34853915+m0n3r0@users.noreply.github.com": "m0n3r0",
|
||||
"leovillalbajr@gmail.com": "Lempkey",
|
||||
"nidhi2894@gmail.com": "nidhi-singh02",
|
||||
"30312689+aashizpoudel@users.noreply.github.com": "aashizpoudel",
|
||||
|
||||
@@ -14,6 +14,8 @@ Tests cover:
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import time
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@@ -128,6 +130,37 @@ class TestResponseStore:
|
||||
# resp_2 mapping should still be intact
|
||||
assert store.get_conversation("chat-b") == "resp_2"
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are platform-specific")
|
||||
def test_file_store_created_owner_only_under_permissive_umask(self, tmp_path):
|
||||
"""response_store.db must be 0o600 on creation even under umask 022."""
|
||||
db_path = tmp_path / "response_store.db"
|
||||
store = None
|
||||
old_umask = os.umask(0o022)
|
||||
try:
|
||||
store = ResponseStore(max_size=10, db_path=str(db_path))
|
||||
store.put(
|
||||
"resp_secret",
|
||||
{
|
||||
"response": {"id": "resp_secret"},
|
||||
"conversation_history": [{"role": "tool", "content": "dummy-marker"}],
|
||||
},
|
||||
)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
if store is not None:
|
||||
store.close()
|
||||
|
||||
assert stat.S_IMODE(db_path.stat().st_mode) == 0o600
|
||||
# WAL/SHM sidecars are owner-only too when present. WAL mode may be
|
||||
# unavailable on some filesystems (NFS/SMB) — only assert when the
|
||||
# sidecar files actually exist.
|
||||
for sidecar in (
|
||||
db_path.with_name(db_path.name + "-wal"),
|
||||
db_path.with_name(db_path.name + "-shm"),
|
||||
):
|
||||
if sidecar.exists():
|
||||
assert stat.S_IMODE(sidecar.stat().st_mode) == 0o600
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _IdempotencyCache
|
||||
|
||||
@@ -172,42 +172,49 @@ def test_bot_bypass_does_not_leak_to_other_platforms(monkeypatch):
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# DISCORD_ALLOWED_ROLES gateway-layer bypass (#7871)
|
||||
# DISCORD_ALLOWED_ROLES no longer bypasses the gateway allowlist (#30742)
|
||||
#
|
||||
# Prior behavior: setting DISCORD_ALLOWED_ROLES caused _is_user_authorized
|
||||
# to return True for ANY Discord event, on the assumption that the adapter
|
||||
# pre-filter had already validated role membership. That allowed slash
|
||||
# commands and synthetic voice events to bypass role checks. PR #30742
|
||||
# removed the shortcut — Discord auth now flows through the same allowlist
|
||||
# / pairing / allow-all path as every other platform.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_discord_role_config_bypasses_gateway_allowlist(monkeypatch):
|
||||
"""When DISCORD_ALLOWED_ROLES is set, _is_user_authorized must trust
|
||||
the adapter's pre-filter and authorize. Without this, role-only setups
|
||||
(DISCORD_ALLOWED_ROLES populated, DISCORD_ALLOWED_USERS empty) would
|
||||
hit the 'no allowlists configured' branch and get rejected.
|
||||
def test_discord_role_config_does_not_bypass_gateway_allowlist(monkeypatch):
|
||||
"""DISCORD_ALLOWED_ROLES alone must NOT authorize at the gateway layer
|
||||
(regression guard for #30742). Role-based access is enforced by the
|
||||
adapter pre-filter on real message events; the gateway layer requires
|
||||
an explicit allowlist hit or pairing approval.
|
||||
"""
|
||||
runner = _make_bare_runner()
|
||||
|
||||
monkeypatch.setenv("DISCORD_ALLOWED_ROLES", "1493705176387948674")
|
||||
# Note: DISCORD_ALLOWED_USERS is NOT set — the entire point.
|
||||
# DISCORD_ALLOWED_USERS deliberately NOT set — verifies the role
|
||||
# config alone no longer grants authorization.
|
||||
|
||||
source = _make_discord_human_source(user_id="999888777")
|
||||
assert runner._is_user_authorized(source) is True
|
||||
assert runner._is_user_authorized(source) is False
|
||||
|
||||
|
||||
def test_discord_role_config_still_authorizes_alongside_users(monkeypatch):
|
||||
"""Sanity: setting both DISCORD_ALLOWED_ROLES and DISCORD_ALLOWED_USERS
|
||||
doesn't break the user-id path. Users in the allowlist should still be
|
||||
authorized even if they don't have a role. (OR semantics.)
|
||||
def test_discord_user_allowlist_still_authorizes_when_role_is_also_configured(monkeypatch):
|
||||
"""Sanity: DISCORD_ALLOWED_USERS still authorizes users on the list,
|
||||
independent of DISCORD_ALLOWED_ROLES. This guards against a future
|
||||
regression that ties the user-allowlist check to the (now-removed)
|
||||
role bypass.
|
||||
"""
|
||||
runner = _make_bare_runner()
|
||||
|
||||
monkeypatch.setenv("DISCORD_ALLOWED_ROLES", "1493705176387948674")
|
||||
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300")
|
||||
|
||||
# User on the user allowlist, no role → still authorized at gateway
|
||||
# level via the role bypass (adapter already approved them).
|
||||
source = _make_discord_human_source(user_id="100200300")
|
||||
assert runner._is_user_authorized(source) is True
|
||||
|
||||
|
||||
def test_discord_role_bypass_does_not_leak_to_other_platforms(monkeypatch):
|
||||
def test_discord_role_config_does_not_leak_to_other_platforms(monkeypatch):
|
||||
"""DISCORD_ALLOWED_ROLES must only affect Discord. Setting it should
|
||||
not suddenly start authorizing Telegram users whose platform has its
|
||||
own empty allowlist.
|
||||
|
||||
@@ -167,6 +167,7 @@ class TestFeishuAdapterMessaging(unittest.TestCase):
|
||||
"FEISHU_WEBHOOK_HOST": "127.0.0.1",
|
||||
"FEISHU_WEBHOOK_PORT": "9001",
|
||||
"FEISHU_WEBHOOK_PATH": "/hook",
|
||||
"FEISHU_VERIFICATION_TOKEN": "vtok",
|
||||
}, clear=True)
|
||||
def test_connect_webhook_mode_starts_local_server(self):
|
||||
from gateway.config import PlatformConfig
|
||||
@@ -1538,6 +1539,34 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
self.assertEqual(response.status, 200)
|
||||
adapter._on_message_event.assert_called_once()
|
||||
|
||||
@patch.dict(os.environ, {"FEISHU_VERIFICATION_TOKEN": "expected-token"}, clear=True)
|
||||
def test_url_verification_requires_configured_verification_token(self):
|
||||
"""url_verification must be rejected when token is set but mismatched.
|
||||
|
||||
Regression: previously the challenge was reflected before the token
|
||||
check, so an unauthenticated remote could prove endpoint control by
|
||||
sending an attacker-controlled challenge string.
|
||||
"""
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
body = json.dumps({
|
||||
"type": "url_verification",
|
||||
"token": "wrong-token",
|
||||
"challenge": "attacker-controlled-challenge",
|
||||
}).encode("utf-8")
|
||||
request = SimpleNamespace(
|
||||
remote="203.0.113.10",
|
||||
content_length=None,
|
||||
headers={},
|
||||
read=AsyncMock(return_value=body),
|
||||
)
|
||||
|
||||
response = asyncio.run(adapter._handle_webhook_request(request))
|
||||
|
||||
self.assertEqual(response.status, 401)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_process_inbound_message_uses_event_sender_identity_only(self):
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
@@ -506,6 +506,7 @@ class TestCardActionCallbackResponse:
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
adapter._allowed_group_users = {"ou_user1"}
|
||||
adapter._approval_state[2] = {
|
||||
"session_key": "sess-2",
|
||||
"message_id": "msg-2",
|
||||
@@ -552,6 +553,7 @@ class TestCardActionCallbackResponse:
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
adapter._allowed_group_users = {"ou_unknown"}
|
||||
adapter._approval_state[3] = {
|
||||
"session_key": "sess-3",
|
||||
"message_id": "msg-3",
|
||||
@@ -572,6 +574,7 @@ class TestCardActionCallbackResponse:
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
adapter._allowed_group_users = {"ou_expired"}
|
||||
adapter._approval_state[4] = {
|
||||
"session_key": "sess-4",
|
||||
"message_id": "msg-4",
|
||||
|
||||
@@ -77,7 +77,8 @@ class TestMSGraphValidationHandshake:
|
||||
adapter = MSGraphWebhookAdapter(PlatformConfig(enabled=True, extra={}))
|
||||
connected = await adapter.connect()
|
||||
assert connected is False
|
||||
assert adapter.is_connected() is False
|
||||
# is_connected is a @property on the base adapter, not a method.
|
||||
assert adapter.is_connected is False
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_validation_token_echo_on_get(self):
|
||||
|
||||
@@ -15,6 +15,7 @@ Covers:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
@@ -100,6 +101,18 @@ def _generic_signature(body: bytes, secret: str) -> str:
|
||||
return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _svix_signature(body: bytes, secret: str, msg_id: str, timestamp: str) -> str:
|
||||
"""Compute a Svix v1 signature header for *body* using *secret*."""
|
||||
key = (
|
||||
base64.b64decode(secret.removeprefix("whsec_"))
|
||||
if secret.startswith("whsec_")
|
||||
else secret.encode()
|
||||
)
|
||||
signed = msg_id.encode() + b"." + timestamp.encode() + b"." + body
|
||||
digest = hmac.new(key, signed, hashlib.sha256).digest()
|
||||
return "v1," + base64.b64encode(digest).decode()
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Signature validation
|
||||
# ===================================================================
|
||||
@@ -170,6 +183,134 @@ class TestValidateSignature:
|
||||
req = _mock_request(headers={"X-Webhook-Signature": sig})
|
||||
assert adapter._validate_signature(req, body, secret) is True
|
||||
|
||||
def test_validate_svix_signature_valid(self):
|
||||
"""Valid Svix/AgentMail v1 signature headers are accepted."""
|
||||
adapter = _make_adapter()
|
||||
body = b'{"event_type":"message.received"}'
|
||||
secret = "whsec_" + base64.b64encode(b"agentmail-signing-secret").decode()
|
||||
msg_id = "msg_123"
|
||||
timestamp = str(int(time.time()))
|
||||
sig = _svix_signature(body, secret, msg_id, timestamp)
|
||||
req = _mock_request(
|
||||
headers={
|
||||
"svix-id": msg_id,
|
||||
"svix-timestamp": timestamp,
|
||||
"svix-signature": sig,
|
||||
}
|
||||
)
|
||||
assert adapter._validate_signature(req, body, secret) is True
|
||||
|
||||
def test_validate_svix_signature_wrong_body_rejects(self):
|
||||
"""Svix/AgentMail signatures are bound to the exact raw request body."""
|
||||
adapter = _make_adapter()
|
||||
signed_body = b'{"event_type":"message.received"}'
|
||||
received_body = b'{"event_type":"message.sent"}'
|
||||
secret = "whsec_" + base64.b64encode(b"agentmail-signing-secret").decode()
|
||||
msg_id = "msg_123"
|
||||
timestamp = str(int(time.time()))
|
||||
sig = _svix_signature(signed_body, secret, msg_id, timestamp)
|
||||
req = _mock_request(
|
||||
headers={
|
||||
"svix-id": msg_id,
|
||||
"svix-timestamp": timestamp,
|
||||
"svix-signature": sig,
|
||||
}
|
||||
)
|
||||
assert adapter._validate_signature(req, received_body, secret) is False
|
||||
|
||||
def test_validate_svix_signature_old_timestamp_rejects(self):
|
||||
"""Svix/AgentMail signatures outside the replay window are rejected."""
|
||||
adapter = _make_adapter()
|
||||
body = b'{"event_type":"message.received"}'
|
||||
secret = "whsec_" + base64.b64encode(b"agentmail-signing-secret").decode()
|
||||
msg_id = "msg_123"
|
||||
timestamp = str(int(time.time()) - 301)
|
||||
sig = _svix_signature(body, secret, msg_id, timestamp)
|
||||
req = _mock_request(
|
||||
headers={
|
||||
"svix-id": msg_id,
|
||||
"svix-timestamp": timestamp,
|
||||
"svix-signature": sig,
|
||||
}
|
||||
)
|
||||
assert adapter._validate_signature(req, body, secret) is False
|
||||
|
||||
def test_validate_svix_signature_multiple_entries_accepts_matching_v1(self):
|
||||
"""Svix rotation headers may contain multiple space-separated signatures."""
|
||||
adapter = _make_adapter()
|
||||
body = b'{"event_type":"message.received"}'
|
||||
secret = "whsec_" + base64.b64encode(b"agentmail-signing-secret").decode()
|
||||
msg_id = "msg_123"
|
||||
timestamp = str(int(time.time()))
|
||||
sig = _svix_signature(body, secret, msg_id, timestamp)
|
||||
req = _mock_request(
|
||||
headers={
|
||||
"svix-id": msg_id,
|
||||
"svix-timestamp": timestamp,
|
||||
"svix-signature": "v1,wrong " + sig,
|
||||
}
|
||||
)
|
||||
assert adapter._validate_signature(req, body, secret) is True
|
||||
|
||||
def test_validate_svix_signature_missing_signature_rejects(self):
|
||||
"""Partial Svix headers reject instead of falling through to another scheme."""
|
||||
adapter = _make_adapter()
|
||||
req = _mock_request(headers={"svix-id": "msg_123"})
|
||||
assert adapter._validate_signature(req, b"{}", "secret") is False
|
||||
|
||||
def test_validate_svix_signature_unsupported_version_rejects(self):
|
||||
"""Only Svix v1 signatures are accepted."""
|
||||
adapter = _make_adapter()
|
||||
body = b'{"event_type":"message.received"}'
|
||||
secret = "whsec_" + base64.b64encode(b"agentmail-signing-secret").decode()
|
||||
msg_id = "msg_123"
|
||||
timestamp = str(int(time.time()))
|
||||
sig = _svix_signature(body, secret, msg_id, timestamp).replace("v1,", "v2,")
|
||||
req = _mock_request(
|
||||
headers={
|
||||
"svix-id": msg_id,
|
||||
"svix-timestamp": timestamp,
|
||||
"svix-signature": sig,
|
||||
}
|
||||
)
|
||||
assert adapter._validate_signature(req, body, secret) is False
|
||||
|
||||
def test_validate_svix_signature_invalid_whsec_rejects(self):
|
||||
"""Malformed whsec_ secrets are rejected, not silently treated as raw secrets."""
|
||||
adapter = _make_adapter()
|
||||
body = b'{"event_type":"message.received"}'
|
||||
malformed_secret = "whsec_not-valid-base64!"
|
||||
msg_id = "msg_123"
|
||||
timestamp = str(int(time.time()))
|
||||
raw_sig = _svix_signature(
|
||||
body, malformed_secret.removeprefix("whsec_"), msg_id, timestamp
|
||||
)
|
||||
req = _mock_request(
|
||||
headers={
|
||||
"svix-id": msg_id,
|
||||
"svix-timestamp": timestamp,
|
||||
"svix-signature": raw_sig,
|
||||
}
|
||||
)
|
||||
assert adapter._validate_signature(req, body, malformed_secret) is False
|
||||
|
||||
def test_validate_svix_signature_raw_secret_valid(self):
|
||||
"""Raw shared secrets are accepted for Svix-style senders without whsec_ secrets."""
|
||||
adapter = _make_adapter()
|
||||
body = b'{"event_type":"message.received"}'
|
||||
secret = "raw-agentmail-secret"
|
||||
msg_id = "msg_123"
|
||||
timestamp = str(int(time.time()))
|
||||
sig = _svix_signature(body, secret, msg_id, timestamp)
|
||||
req = _mock_request(
|
||||
headers={
|
||||
"svix-id": msg_id,
|
||||
"svix-timestamp": timestamp,
|
||||
"svix-signature": sig,
|
||||
}
|
||||
)
|
||||
assert adapter._validate_signature(req, body, secret) is True
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Prompt rendering
|
||||
@@ -304,6 +445,27 @@ class TestEventFilter:
|
||||
)
|
||||
assert resp.status == 202
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_filter_accepts_payload_type_field(self):
|
||||
"""Svix-style payloads often use a top-level `type` event field."""
|
||||
routes = {
|
||||
"svix": {
|
||||
"secret": _INSECURE_NO_AUTH,
|
||||
"events": ["message.received"],
|
||||
"prompt": "got it",
|
||||
}
|
||||
}
|
||||
adapter = _make_adapter(routes=routes)
|
||||
adapter.handle_message = AsyncMock()
|
||||
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.post(
|
||||
"/webhooks/svix",
|
||||
json={"type": "message.received"},
|
||||
)
|
||||
assert resp.status == 202
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# HTTP handling
|
||||
@@ -336,6 +498,22 @@ class TestHTTPHandling:
|
||||
assert data["status"] == "accepted"
|
||||
assert data["route"] == "test"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_route_without_secret_rejects_unsigned_request(self):
|
||||
"""Missing HMAC secret must fail closed even if connect() was bypassed."""
|
||||
routes = {"test": {"prompt": "hi"}}
|
||||
adapter = _make_adapter(routes=routes, secret="")
|
||||
adapter.handle_message = AsyncMock()
|
||||
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.post("/webhooks/test", json={"data": "value"})
|
||||
assert resp.status == 403
|
||||
data = await resp.json()
|
||||
assert data["error"] == "Webhook route is missing an HMAC secret"
|
||||
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_endpoint(self):
|
||||
"""GET /health returns 200 with status=ok."""
|
||||
@@ -432,6 +610,25 @@ class TestIdempotency:
|
||||
resp2 = await cli.post("/webhooks/idem", json={"x": 1}, headers=headers)
|
||||
assert resp2.status == 202 # re-accepted
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_svix_id_used_as_delivery_id_for_deduplication(self):
|
||||
"""Svix retries reuse svix-id, so use it as the delivery ID when present."""
|
||||
routes = {"idem": {"secret": _INSECURE_NO_AUTH, "prompt": "test"}}
|
||||
adapter = _make_adapter(routes=routes)
|
||||
adapter.handle_message = AsyncMock()
|
||||
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
headers = {"svix-id": "msg_duplicate"}
|
||||
resp1 = await cli.post("/webhooks/idem", json={"a": 1}, headers=headers)
|
||||
assert resp1.status == 202
|
||||
|
||||
resp2 = await cli.post("/webhooks/idem", json={"a": 1}, headers=headers)
|
||||
assert resp2.status == 200
|
||||
data = await resp2.json()
|
||||
assert data["status"] == "duplicate"
|
||||
assert data["delivery_id"] == "msg_duplicate"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Rate limiting
|
||||
|
||||
@@ -327,6 +327,12 @@ class TestWebServerEndpoints:
|
||||
# Public endpoints should still work
|
||||
resp = unauth_client.get("/api/status")
|
||||
assert resp.status_code == 200
|
||||
resp = unauth_client.get("/api/dashboard/plugins")
|
||||
assert resp.status_code == 200
|
||||
resp = unauth_client.get("/api/dashboard/plugins/rescan")
|
||||
assert resp.status_code == 401
|
||||
resp = self.client.get("/api/dashboard/plugins/rescan")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_path_traversal_blocked(self):
|
||||
"""Verify URL-encoded path traversal is blocked."""
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import os
|
||||
import pytest
|
||||
import stat
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
|
||||
@@ -145,6 +146,31 @@ class TestPersistence:
|
||||
path.write_text("broken{{{")
|
||||
assert _load_subscriptions() == {}
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are platform-specific")
|
||||
def test_save_creates_secret_file_owner_only_under_permissive_umask(self):
|
||||
old_umask = os.umask(0o022)
|
||||
try:
|
||||
_save_subscriptions({"demo": {"secret": "TOPSECRET", "prompt": "x"}})
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
path = _subscriptions_path()
|
||||
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
||||
assert "TOPSECRET" in path.read_text(encoding="utf-8")
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are platform-specific")
|
||||
def test_save_narrows_existing_broad_secret_file_mode(self):
|
||||
# Simulate a pre-existing 0o644 file from before this hardening landed.
|
||||
path = _subscriptions_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"old": {"secret": "stale", "prompt": "x"}}))
|
||||
path.chmod(0o644)
|
||||
|
||||
_save_subscriptions({"demo": {"secret": "FRESH", "prompt": "x"}})
|
||||
|
||||
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
||||
assert "FRESH" in path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestWebhookEnabledGate:
|
||||
def test_blocks_when_disabled(self, capsys, monkeypatch):
|
||||
|
||||
@@ -304,3 +304,52 @@ def test_config_enabled_hard_stop_run_conversation_returns_controlled_guardrail_
|
||||
call_ids = [tc["id"] for tc in assistant_msg["tool_calls"]]
|
||||
following_results = [m for m in result["messages"] if m.get("role") == "tool" and m.get("tool_call_id") in call_ids]
|
||||
assert len(following_results) == len(call_ids)
|
||||
|
||||
|
||||
def test_guardrail_halt_emits_final_response_through_stream_delta_callback():
|
||||
"""Regression for #30770: when the guardrail halts the loop, the
|
||||
synthesized halt message must be pushed through ``stream_delta_callback``
|
||||
so SSE/TUI clients see why the agent stopped instead of a silent stream
|
||||
close. Without this the chat-completions SSE writer drains an empty
|
||||
queue and emits a finish chunk with zero content (indistinguishable
|
||||
from a crash for Open WebUI and similar clients).
|
||||
"""
|
||||
agent = _make_agent("web_search", max_iterations=10, config=_hard_stop_config())
|
||||
same_args = {"query": "same"}
|
||||
responses = [
|
||||
_mock_response(
|
||||
content="",
|
||||
finish_reason="tool_calls",
|
||||
tool_calls=[_mock_tool_call("web_search", json.dumps(same_args), f"c{i}")],
|
||||
)
|
||||
for i in range(1, 10)
|
||||
]
|
||||
agent.client.chat.completions.create.side_effect = responses
|
||||
|
||||
deltas: list = []
|
||||
agent.stream_delta_callback = lambda d: deltas.append(d)
|
||||
# The mocked client returns SimpleNamespace responses which aren't
|
||||
# iterable as streaming chunks; force the non-streaming code path so
|
||||
# the guardrail-halt branch is reached without engaging the real
|
||||
# streaming machinery.
|
||||
agent._disable_streaming = True
|
||||
|
||||
with (
|
||||
patch("run_agent.handle_function_call", return_value=json.dumps({"error": "boom"})),
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
result = agent.run_conversation("search repeatedly")
|
||||
|
||||
assert result["turn_exit_reason"] == "guardrail_halt"
|
||||
halt_text = result["final_response"]
|
||||
assert "stopped retrying" in halt_text
|
||||
|
||||
# The halt message must have been pushed through the callback at least
|
||||
# once. Empty-queue SSE writers were the bug — clients saw no content
|
||||
# delta before the finish chunk.
|
||||
text_deltas = [d for d in deltas if isinstance(d, str)]
|
||||
assert halt_text in text_deltas, (
|
||||
f"halt message was never streamed; callback only saw {deltas!r}"
|
||||
)
|
||||
|
||||
@@ -48,8 +48,14 @@ def _process_group_snapshot(pgid: int) -> str:
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def _wait_for_pgid_exit(pgid: int, timeout: float = 10.0) -> bool:
|
||||
"""Wait for a process group to disappear under loaded xdist hosts."""
|
||||
def _wait_for_pgid_exit(pgid: int, timeout: float = 30.0) -> bool:
|
||||
"""Wait for a process group to disappear under loaded xdist hosts.
|
||||
|
||||
The cleanup chain is: SIGTERM → 3s TimeoutStopSec → SIGKILL → reap.
|
||||
Under heavy xdist load (40 parallel workers, 6-shard CI), the full
|
||||
sequence can exceed 10s. Default timeout is generous to avoid CI
|
||||
flakes; in practice the wait returns in <1s on quiet hosts.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if not _pgid_still_alive(pgid):
|
||||
@@ -166,9 +172,11 @@ def test_wait_for_process_kills_subprocess_on_keyboardinterrupt():
|
||||
assert ret == 1, f"SetAsyncExc returned {ret}, expected 1"
|
||||
|
||||
# Give the worker a moment to: hit the exception at the next poll,
|
||||
# run the except-block cleanup (_kill_process), and exit.
|
||||
t.join(timeout=5.0)
|
||||
assert not t.is_alive(), "worker didn't exit within 5 s of the interrupt"
|
||||
# run the except-block cleanup (_kill_process), and exit. Under
|
||||
# xdist load the SIGTERM → 3s wait → SIGKILL chain can take longer
|
||||
# than 5s before the worker's join() returns; bumped to 15s.
|
||||
t.join(timeout=15.0)
|
||||
assert not t.is_alive(), "worker didn't exit within 15 s of the interrupt"
|
||||
|
||||
# The critical assertion: the subprocess GROUP must be dead. Not
|
||||
# just the bash wrapper — the 'sleep 30' child too. Under xdist load,
|
||||
|
||||
@@ -93,7 +93,7 @@ FEISHU_WEBHOOK_PORT=8765 # default: 8765
|
||||
FEISHU_WEBHOOK_PATH=/feishu/webhook # default: /feishu/webhook
|
||||
```
|
||||
|
||||
When Feishu sends a URL verification challenge (`type: url_verification`), the webhook responds automatically so you can complete the subscription setup in the Feishu developer console.
|
||||
When Feishu sends a URL verification challenge (`type: url_verification`), the webhook responds automatically so you can complete the subscription setup in the Feishu developer console. The challenge response is gated on `FEISHU_VERIFICATION_TOKEN` when set — challenge requests with a missing or mismatched token are rejected so an unauthenticated remote cannot prove endpoint control by echoing attacker-controlled challenge data.
|
||||
|
||||
## Step 3: Configure Hermes
|
||||
|
||||
|
||||
Reference in New Issue
Block a user