Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -10677,9 +10677,15 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
_skill_names = [_auto] if isinstance(_auto, str) else list(_auto)
try:
from agent.skill_commands import _load_skill_payload, _build_skill_message
from agent.skill_utils import get_disabled_skill_names as _get_plat_disabled
_plat = source.platform.value if source.platform else None
_plat_disabled = _get_plat_disabled(platform=_plat) if _plat else set()
_combined_parts: list[str] = []
_loaded_names: list[str] = []
for _sname in _skill_names:
if _sname in _plat_disabled:
logger.info("[Gateway] Skipping disabled auto-skill '%s' for platform '%s'", _sname, _plat)
continue
_loaded = _load_skill_payload(_sname, task_id=_quick_key)
if _loaded:
_loaded_skill, _skill_dir, _display_name = _loaded
Expand Down
28 changes: 27 additions & 1 deletion hermes_cli/dashboard_auth/prefix.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,38 @@ def normalise_prefix(raw: Optional[str]) -> str:
or ".." in p
or any(c in p for c in _REJECT_CHARS)
):
_warn_malformed_prefix(raw)
return ""
if len(p) > 64:
if len(p) > 256:
_warn_malformed_prefix(raw)
return ""
return p


def _warn_malformed_prefix(raw: str) -> None:
"""Warn (once per distinct value) when a non-empty X-Forwarded-Prefix
was rejected by :func:`normalise_prefix`.

Mirrors the dedup pattern in :func:`_warn_if_malformed_public_url` so
a misconfigured proxy doesn't flood the logs.
"""
p = raw.strip() if raw else ""
if not p:
return
key = p
if key in _warned_malformed_public_urls:
return
_warned_malformed_public_urls.add(key)
_log.warning(
"X-Forwarded-Prefix header value %r was ignored because it is "
"malformed (path traversal, control chars, or exceeds 256 chars). "
"Dashboard asset URLs and cookie paths will be unprefixed; SPA "
"will likely serve a blank page behind the proxy. Check your "
"reverse-proxy configuration.",
raw,
)


def prefix_from_request(request) -> str:
"""Convenience wrapper that reads the header off a Starlette/FastAPI
Request and normalises it. Returns ``""`` when no prefix.
Expand Down
67 changes: 67 additions & 0 deletions tests/gateway/test_auto_skill_platform_disabled.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""
Regression test for PR #59478 fix: auto-skill channel bindings must respect
platform-disabled skill gates.

The `_handle_message_with_agent()` auto-skill block (Telegram DM Topics,
Discord `channel_skill_bindings`) loads bound skills via
`_load_skill_payload()` with a raw identifier, bypassing
`get_skill_commands()`'s scan-time disabled filter. Result: a skill an
operator disables for a platform (or globally via `skills.disabled`) still
gets its full content injected into every new session bound to that
channel/topic.

This test mirrors the approach used in
`test_10710_auto_reset_evicts_cached_agent.py` — `_handle_message_with_agent`
requires a large mocked harness to invoke directly, so we assert the fix
indirectly by verifying the source code of the relevant block in
`gateway/run.py` contains the expected disabled-skill check.
"""

import pathlib


def test_auto_skill_block_checks_platform_disabled_gate():
"""
Verify that the auto-skill loading block in
`_handle_message_with_agent()` checks `get_disabled_skill_names(platform=...)`
before calling `_load_skill_payload()` for each auto-skill.
"""
run_py = pathlib.Path(__file__).parents[2] / "gateway" / "run.py"
source = run_py.read_text(encoding="utf-8")

# Verify the key elements are present in the auto-skill block
# 1. Import of get_disabled_skill_names (with alias _get_plat_disabled)
assert "from agent.skill_utils import get_disabled_skill_names as _get_plat_disabled" in source, (
"Missing import of get_disabled_skill_names as _get_plat_disabled"
)
# 2. Call to get_disabled_skill_names with platform argument (via alias)
assert "_get_plat_disabled(platform=" in source or "_get_plat_disabled(platform =" in source, (
"Missing call to _get_plat_disabled(platform=...)"
)
# 3. Check for the skip condition
assert "_sname in _plat_disabled" in source, (
"Missing check to skip disabled skills (_sname in _plat_disabled)"
)
# 4. Continue statement to skip disabled skill
assert "continue" in source and "Skipping disabled auto-skill" in source, (
"Missing continue/logic for skipping disabled skills"
)


def test_auto_skill_block_logs_skipped_disabled_skills():
"""
Verify that the auto-skill block logs when skipping a disabled skill.
"""
run_py = pathlib.Path(__file__).parents[2] / "gateway" / "run.py"
source = run_py.read_text(encoding="utf-8")

# Search for the log message pattern
assert "Skipping disabled auto-skill" in source, (
"Expected log message for skipped disabled auto-skill not found"
)


if __name__ == "__main__":
test_auto_skill_block_checks_platform_disabled_gate()
test_auto_skill_block_logs_skipped_disabled_skills()
print("All tests passed!")
Loading