Skip to content

fix(gateway): surface rejected media paths instead of dropping silently#31864

Open
Masterlincs wants to merge 1 commit into
NousResearch:mainfrom
Masterlincs:fix/media-surface-rejections
Open

fix(gateway): surface rejected media paths instead of dropping silently#31864
Masterlincs wants to merge 1 commit into
NousResearch:mainfrom
Masterlincs:fix/media-surface-rejections

Conversation

@Masterlincs

Copy link
Copy Markdown

What does this PR do?

When a model emits MEDIA:/path or a bare local path that falls outside the allowed roots (image_cache, audio_cache, document_cache, etc., or operator-configured HERMES_MEDIA_ALLOW_DIRS), filter_media_delivery_paths and filter_local_delivery_paths previously logged a WARNING and returned only the surviving paths — the rejection vanished. The user saw text without the promised attachment and nothing in the response indicated why; the only trace was a gateway log line.

This change:

  • Changes both filter functions to return (accepted, rejected_basenames) so callers can react. Basenames (not full paths) are surfaced to avoid leaking filesystem layout into chat output.
  • Adds BasePlatformAdapter.format_media_rejection_notice() so the user-visible string lives in one place.
  • Updates every call site (13 total across base.py, weixin.py, run.py, scheduler.py, send_message_tool.py, yuanbao_tools.py) to unpack the new tuple and either append the notice to outgoing text or send it as a follow-up message when text has already streamed.

Breaking change to the filter_media_delivery_paths / filter_local_delivery_paths signatures. Both are part of BasePlatformAdapter's static-method surface; third-party platform plugins that call them will need a one-line unpack update. Happy to split into deprecated wrappers + new variants if maintainers prefer.

Related Issue

No related issue — surfaced while testing media delivery on a personal Discord deployment.

Fixes #

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Note: this is a behavior-visible bug fix but the static-method signatures change. Marked as bug fix because the user-facing observable change is purely "silent failure → visible notice."

Changes Made

  • gateway/platforms/base.py
    • filter_media_delivery_paths returns (accepted, rejected_basenames)
    • filter_local_delivery_paths returns (accepted, rejected_basenames)
    • New format_media_rejection_notice(rejected_basenames) -> str helper
    • _process_message_background unpacks new tuples and appends notice to text_content
  • gateway/platforms/weixin.py — same pattern; appends notice to final_content
  • gateway/run.py
    • Kanban notifier (:5154) — sends notice as standalone message (no surrounding text)
    • _deliver_media_from_response (:11355/11358) — sends notice as follow-up (text already streamed)
    • Background task delivery (:11669) — appends notice to header+text payload
  • cron/scheduler.py
    • _send_media_via_adapter — defensive notice send (normally a no-op; _deliver_result already filters)
    • _deliver_result — appends notice to cleaned_delivery_content
  • tools/send_message_tool.py — appends notice to cleaned_message
  • tools/yuanbao_tools.py — appends notice to message
  • tests/gateway/test_platform_base.py
    • Updated test_filter_keeps_safe_media_and_drops_unsafe to assert new tuple shape
    • New: test_filter_media_rejected_basename_strips_quotes
    • New: test_filter_local_returns_rejected_basenames
    • New: test_filter_empty_input_returns_empty_pair
    • New: test_rejection_notice_empty_when_nothing_rejected
    • New: test_rejection_notice_lists_basenames
    • New: test_extract_filter_notice_composition (end-to-end)
  • tests/tools/test_send_message_tool.py
    • Updated test_media_tag_outside_allowed_roots_is_not_sent to assert the new visible-notice behavior (was asserting the silent-failure behavior this PR fixes)

How to Test

  1. Automated: scripts/run_tests.sh tests/gateway/test_platform_base.py tests/tools/test_send_message_tool.py — all relevant tests pass (350/350 across the changed paths and their adjacent
    suites).
  2. Manual reproduction of the bug (without this fix): with a running gateway, ask the bot on any chat platform (Discord/Telegram/etc.) to deliver a file outside the safe roots: "Save a small test
    file to /tmp/test.txt and upload it here."
    You'll see the bot's reply text but no attachment, and grep "Skipping unsafe" ~/.hermes/logs/gateway.log is the only trace.
  3. With this fix: same prompt now also surfaces ⚠️ Couldn't attach: test.txt (path outside allowed roots) in the reply.
  4. Regression check (success path unchanged): write a file into ~/.hermes/document_cache/ instead and ask the bot to deliver it. Should still arrive as an attachment, no notice (existing
    behavior).

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(gateway): …)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass — all tests related to the changed code paths pass (350/350 across tests/gateway/, tests/tools/test_send_message_tool.py,
    tests/hermes_cli/test_kanban_notify.py, tests/test_yuanbao_pipeline.py). The full scripts/run_tests.sh tests/ run surfaces ~45 pre-existing failures in unrelated areas (process management, MCP
    stability, voice/TTS, plugin scanner) that reproduce on clean upstream/main without these changes applied; none touch media filtering.
  • I've added tests for my changes (6 new tests + 2 updated)
  • I've tested on my platform: Ubuntu (Linux), Python 3.11

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — docstrings on both filter functions updated to describe the new return shape and the rationale
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A (no config keys added)
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact — scripts/check-windows-footguns.py --diff upstream/main returned clean; change uses only os.path.basename + string ops, no platform-specific syscalls
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A (no tool schemas changed; send_message and yuanbao_tools internal handlers updated but visible tool schemas unchanged)

Screenshots / Logs

Before the fix (gateway log shows the silent rejection):

2026-05-23 22:41:54,978 WARNING gateway.platforms.base: Skipping unsafe local file path outside allowed roots
2026-05-23 22:41:54,979 INFO gateway.platforms.base: [Discord] Sending response (737 chars) to

…and the user's chat shows the bot's text reply with no attachment and no explanation.

After the fix (verified live on Discord):

⚠️ Couldn't attach: gibberish.txt (path outside allowed roots)

…appended to the same reply, telling the user exactly what failed and which file.

Copilot AI review requested due to automatic review settings May 25, 2026 04:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR makes media attachment filtering non-silent by returning rejected file basenames from allowlist filtering and surfacing a user-visible rejection notice across multiple send paths.

Changes:

  • Update BasePlatformAdapter.filter_*_delivery_paths to return (accepted, rejected_basenames) and add format_media_rejection_notice().
  • Append (or send) rejection notices in multiple message delivery flows (tools, gateway, cron, platform adapter).
  • Add/extend tests to validate acceptance/rejection behavior and notice composition.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tools/yuanbao_tools.py Appends rejection notice to DM text when unsafe MEDIA paths are dropped.
tools/send_message_tool.py Appends rejection notice to outgoing text when unsafe MEDIA paths are dropped.
gateway/platforms/base.py Changes filter APIs to return rejected basenames; adds formatting helper; appends notice in streaming flow.
gateway/run.py Sends post-stream / notifier rejection notices; appends notice to background task messages.
gateway/platforms/weixin.py Appends rejection notice to outgoing content when media/local paths are dropped.
cron/scheduler.py Defensively surfaces rejection notice when media filtering rejects paths.
tests/gateway/test_platform_base.py Expands tests for rejected basenames and notice formatting/composition.
tests/tools/test_send_message_tool.py Updates test to assert user-visible rejection notice is appended.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread gateway/run.py
Comment on lines +5154 to +5160
candidates, rejected = BasePlatformAdapter.filter_local_delivery_paths(candidates)
if rejected:
notice = BasePlatformAdapter.format_media_rejection_notice(rejected)
try:
await adapter.send(chat_id=chat_id, content=notice.lstrip(), metadata=metadata)
except Exception as exc:
logger.warning("kanban notifier: failed to send rejection notice: %s", exc)
Comment thread gateway/platforms/base.py
Comment on lines +2238 to +2240
def filter_media_delivery_paths(
media_files,
) -> Tuple[List[Tuple[str, bool]], List[str]]:
Comment thread gateway/platforms/base.py

@staticmethod
def filter_local_delivery_paths(file_paths) -> List[str]:
"""Drop unsafe bare local file paths and normalize accepted paths."""
def filter_local_delivery_paths(file_paths) -> Tuple[List[str], List[str]]:
Comment thread gateway/platforms/base.py
if safe_path:
safe_media.append((safe_path, bool(is_voice)))
else:
logger.warning("Skipping unsafe MEDIA directive path outside allowed roots")
return safe_media
rejected.append(os.path.basename(raw.strip().strip("`\"'")) or raw)
Comment thread gateway/platforms/base.py
if safe_path:
safe_paths.append(safe_path)
else:
logger.warning("Skipping unsafe local file path outside allowed roots")
return safe_paths
rejected.append(os.path.basename(raw.strip().strip("`\"'")) or raw)
Comment thread gateway/platforms/base.py
Comment on lines +2289 to +2290
names = ", ".join(rejected_basenames)
return f"\n\n⚠️ Couldn't attach: {names} (path outside allowed roots)"
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery comp/tools Tool registry, model_tools, toolsets labels May 25, 2026
When a model emits MEDIA:/path or a bare local path that falls outside
the allowed roots (image_cache, audio_cache, document_cache, etc., or
operator-configured HERMES_MEDIA_ALLOW_DIRS), filter_media_delivery_paths
and filter_local_delivery_paths previously logged a WARNING and returned
only the surviving paths — the rejection vanished. The user saw text
without the promised attachment and nothing in the response indicated
why; the only trace was a gateway log line.

This change:

- Changes both filter functions to return (accepted, rejected_basenames)
  so callers can react. Basenames (not full paths) are surfaced to avoid
  leaking filesystem layout into chat output.
- Adds BasePlatformAdapter.format_media_rejection_notice() so the user-
  visible string lives in one place.
- Updates every call site (13 total across base.py, weixin.py, run.py,
  scheduler.py, send_message_tool.py, yuanbao_tools.py) to unpack the
  new tuple and either append the notice to outgoing text or send it as
  a follow-up message when text has already streamed.

Breaking change to the filter_media_delivery_paths /
filter_local_delivery_paths signatures. Both are part of
BasePlatformAdapter's static-method surface; third-party platform
plugins that call them will need a one-line unpack update. Happy to
split into deprecated wrappers + new variants if maintainers prefer.

Tests:
- Updates the existing filter test to assert the new tuple shape.
- Adds tests for rejected basename extraction (incl. quoted paths),
  empty input handling, the notice helper, and an end-to-end
  composition test (extract -> filter -> notice).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery comp/tools Tool registry, model_tools, toolsets P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants