Compare commits

...

10 Commits

Author SHA1 Message Date
Teknium
186bf25cb1 test(guardrail): assert halt message reaches stream_delta_callback
Some checks failed
Lint (ruff + ty) / ruff + ty diff (push) Has been cancelled
Lint (ruff + ty) / ruff enforcement (blocking) (push) Has been cancelled
Lint (ruff + ty) / Windows footguns (blocking) (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
Tests / test (1) (push) Has been cancelled
Tests / test (2) (push) Has been cancelled
Tests / test (3) (push) Has been cancelled
Tests / test (4) (push) Has been cancelled
Tests / test (5) (push) Has been cancelled
Tests / test (6) (push) Has been cancelled
Tests / save-durations (push) Has been cancelled
Tests / e2e (push) Has been cancelled
Build Skills Index / build-index (push) Has been cancelled
Build Skills Index / deploy-with-index (push) Has been cancelled
Docker Build and Publish / build-amd64 (push) Has been cancelled
Docker Build and Publish / build-arm64 (push) Has been cancelled
Docker Build and Publish / merge (push) Has been cancelled
Docker Build and Publish / move-latest (push) Has been cancelled
Regression guard for #30770 — verifies the guardrail-halt branch in
agent/conversation_loop.py pushes the synthesized halt message through
stream_delta_callback before breaking out of the loop.  Without the
emit, chat-completions SSE writers drain an empty queue and clients
(Open WebUI, etc.) see a finish chunk with zero content delta —
indistinguishable from a crash.

Verified: the test fails when the production fix is reverted.
2026-05-24 07:38:24 -07:00
annguyenNous
38b8d0da85 fix: emit guardrail halt message to client before closing stream
When the tool loop guardrail fires (max_tool_failures, etc.), the
turn exits with guardrail_halt but no final assistant message was
emitted to the client. The SSE stream closed silently —
indistinguishable from a crash.

The stream_delta_callback(None) before tool execution is a display
flush, not a hard close. After generating the halt response, emit
it through both _safe_print (CLI) and stream_delta_callback (SSE)
so clients see the explanation.

Fixes #30770
2026-05-24 07:38:24 -07:00
Teknium
889903f0fa fix(tests): align CI tests with recent security hardening (#31470)
Four recent security PRs landed on main with stale/missing test updates,
breaking 4 test shards on every subsequent PR's CI run:

- test_discord_bot_auth_bypass.py (PR #30742 c3caca658):
  DISCORD_ALLOWED_ROLES no longer bypasses _is_user_authorized.
  Inverted 3 tests to assert the new (correct) behavior: role config
  alone does NOT authorize at the gateway layer.

- test_msgraph_webhook.py (PR #30169 4ca77f105):
  adapter.is_connected is a @property, not a method. Test was calling
  it with () after the connect() change; TypeError: 'bool' is not
  callable. Removed the parens.

- test_feishu_approval_buttons.py (PR #30744 bdb97b857):
  Card-action callbacks now go through _allow_group_message
  authorization. 3 tests in TestCardActionCallbackResponse didn't
  populate adapter._allowed_group_users so the operator's open_id got
  rejected. Added the allowlist setup to each test, matching the
  existing pattern in test_returns_card_for_approve_action.

Also raise tolerance on test_wait_for_process_kills_subprocess_on_keyboardinterrupt:
the SIGTERM → 3s TimeoutStopSec → SIGKILL → reap chain can exceed 10s
under loaded xdist (40 workers). Bumped _wait_for_pgid_exit timeout
10→30s and worker join timeout 5→15s. Passes 100% in isolation
already; this just makes it tolerant of CI-host load.

Validation: 270/270 tests pass across the 5 affected files.
2026-05-24 06:54:16 -07:00
Hinotoi-agent
3bace071bf fix(state): restrict sensitive store file permissions
response_store.db (api server) holds conversation history including tool
payloads, prompts, and results. webhook_subscriptions.json holds per-route
HMAC secrets. Under a permissive umask (e.g. 0o022, default on most
distros) both files were created mode 0o644 — readable by other local
users on shared boxes.

- gateway/platforms/api_server.py: ResponseStore tightens itself + WAL/SHM
  sidecars to 0o600 after __init__, then trusts the inode. (Original
  contributor patch chmod'd after every _commit() — wasteful on a hot
  api_server path; chmod-on-create is sufficient since SQLite preserves
  mode bits across writes.)

- hermes_cli/webhook.py: _save_subscriptions writes via tempfile.mkstemp
  (which itself creates the file with 0o600), chmods the temp before the
  atomic rename, and re-asserts 0o600 on the destination so an existing
  permissive file from before this fix gets narrowed.

Tests cover (a) creation under permissive umask leaves 0o600 and (b) an
existing 0o644 webhook_subscriptions.json gets narrowed on next save.
Tests guarded with skipif os.name=='nt' since POSIX mode bits don't apply
on Windows.

Salvaged from PR #30917 by @Hinotoi-agent. Reworked the api_server.py
side from chmod-on-every-commit to chmod-on-create.

Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
2026-05-24 04:55:18 -07:00
m0n3r0
f378f00bfb fix(feishu): validate verification token before reflecting url_verification challenge
When FEISHU_VERIFICATION_TOKEN is configured, an unauthenticated remote
could previously prove endpoint control by sending a url_verification
payload with any attacker-controlled challenge string — the handler
reflected the challenge BEFORE running the token check.

Move the verification_token check ahead of the url_verification echo so
the challenge response is gated on a valid token. Add a regression test
covering the wrong-token case. Also fix the stale
test_connect_webhook_mode_starts_local_server fixture to set
FEISHU_VERIFICATION_TOKEN (post #30746 webhook mode requires a secret).

Salvaged from PR #29663 by @m0n3r0 — kept the url_verification reorder
and its regression test; dropped the host-conditional weakening of the
#30746 secret guard (we want webhook secrets required regardless of
bind host, not only on 0.0.0.0/::).

Docs updated to call out the gating.

Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
2026-05-24 04:51:19 -07:00
teknium1
5e6749fbf3 chore(release): map m0n3r0 for PR #29629 salvage 2026-05-24 04:47:45 -07:00
teknium1
15aa6884a2 fix(webhook): use 403 not 500 for missing-secret rejection
Operator misconfiguration is a client/setup error, not an internal server
exception. 403 "forbidden" more accurately reflects "this route refuses
to authenticate" than 500 "internal server error" — the latter triggers
incident alerting on operator monitoring and conflates real bugs with
config drift.

Follow-up tweak to PR #29629 by @m0n3r0.
2026-05-24 04:47:45 -07:00
m0n3r0
dbf73e90fa fix: fail closed for webhook routes without secrets
Reject unsigned webhook requests when a route has no effective HMAC secret, even if the request handler is reached without the normal connect-time validation. Add regression coverage for the direct-handler path.
2026-05-24 04:47:45 -07:00
BaxBit
bbf02c3224 fix(gateway): validate Svix webhook signatures (#30200) 2026-05-24 04:45:13 -07:00
Jiaming Guo
ee002e7fc5 fix(dashboard): require auth for plugin rescan (#27340) 2026-05-24 04:45:07 -07:00
18 changed files with 556 additions and 37 deletions

View File

@@ -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

View File

@@ -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)."""

View File

@@ -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)

View File

@@ -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
# ------------------------------------------------------------------

View File

@@ -119,7 +119,6 @@ _PUBLIC_API_PATHS: frozenset = frozenset({
"/api/model/info",
"/api/dashboard/themes",
"/api/dashboard/plugins",
"/api/dashboard/plugins/rescan",
})

View File

@@ -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:

View File

@@ -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",

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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",

View File

@@ -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):

View File

@@ -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

View File

@@ -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."""

View File

@@ -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):

View File

@@ -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}"
)

View File

@@ -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,

View File

@@ -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