Repository navigation
feat(slack): forward inbound files and images to the agent - #7812
Conversation
A message carrying an upload arrived with subtype `file_share`, which the subtype filter dropped outright, so the user's image or clip never reached the agent and the only workaround was to host the file elsewhere and paste a URL. `file_share` now shares the bare-message arm and a message's `files` array becomes an `Image` / `Video` / `Audio` / `File` content variant carrying `url_private_download`. The adapter declares `header_rules` for Slack's own file hosts so the daemon can fetch that URL with the bot token, which Slack requires; the daemon exact-matches the request host against those rules, and the adapter refuses any file URL that is not on one of the same hosts, so a remote file registered by a workspace member cannot pull the token to an address it chose. The URL is forwarded rather than the bytes on purpose: `download_media_blocks` is what turns an inbound URL into a vision image block, an audio transcription or a saved document path, and its match has no `FileData` arm, so inlining bytes would deliver a text placeholder and nothing else. Gated by `SLACK_FILE_DOWNLOADS`, `SLACK_FILE_MAX_BYTES`, `SLACK_FILE_ALLOWED_EXTENSIONS` and the `SLACK_FILE_DOWNLOAD_CHANNELS` / `SLACK_FILE_DOWNLOAD_EXCLUDE_CHANNELS` pair. With forwarding switched off the token is not declared at all. Refs #7087
The trailing `(#N)` group is what suppresses the auto-generated release-note line, so it has to name the PR, not only the issue.
Deploying librefang-docs with
|
| Latest commit: |
2134610
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://902e7ced.librefang-docs.pages.dev |
| Branch Preview URL: | https://feat-7087-slack-inbound-atta.librefang-docs.pages.dev |
CLAUDE.md's prose-wrapping rule bars hard-wrapping doc-comments at a column count; the docstrings and comments this PR added for inbound attachments were wrapped at ~78 columns across sentence boundaries instead. No code changes, comment/docstring text only.
houko
left a comment
There was a problem hiding this comment.
Reviewed against CLAUDE.md. Solid, well-tested change (185/185 pytest, security design checked against the actual Rust fetch_headers_for/download_media_blocks it references). Pushed one mechanical fix: the new docstrings/comments were hard-wrapped at ~78 columns across sentence boundaries, which CLAUDE.md's prose-wrapping rule forbids — reflowed to one sentence per line, no logic changes. One minor test-coverage nit left inline.
Generated by Claude Code
| return False | ||
| if parsed.scheme != "https" or not host: | ||
| return False | ||
| if parsed.username is not None or parsed.password is not None: |
There was a problem hiding this comment.
Nit: the docstring on _is_slack_file_url calls out https://files.slack.com@evil.example/x by name as the reason for this userinfo check, but test_is_slack_file_url_host_pinning doesn't exercise that exact string — it only asserts the plain-host rejection cases. Worth adding one assertion for the userinfo form so the test suite actually covers the attack the comment documents (even though, as far as I can tell, the plain host in SLACK_FILE_HOSTS check would already reject it since urlsplit(...).hostname resolves to evil.example there — so this looks like a coverage gap rather than a live bug).
Generated by Claude Code
Closes #7087
The Slack sidecar was text-only on the inbound side. A message carrying an upload arrives with subtype
file_share, and the subtype filter inparse_slack_eventdropped every subtype other thanmessage_changed, so the user's image or clip was discarded before any content parsing ran.Changes
sdk/python/librefang/sidecar/adapters/slack.pyfile_sharenow shares the bare-message arm; every other subtype is still dropped.parse_slack_files/_file_contentmap a message'sfilesarray ontoContent.image/Content.video/Content.audio/Content.file, keyed onmimetype, carryingurl_private_downloadand using the message text as the caption.duration_msbecomesduration_secondsfor video / audio.ChannelContent), extras counted in a warning; the attachment outranks the message text including a slash command. Both match the discord sidecar's precedent.SlackFilePolicyholds the gate:SLACK_FILE_DOWNLOADS(bool, defaulttrue),SLACK_FILE_MAX_BYTES(default 10 MiB, the same cap as the outbound upload path),SLACK_FILE_ALLOWED_EXTENSIONS(empty = allow all, matching theSLACK_ALLOWED_CHANNELSconvention already in this file), and theSLACK_FILE_DOWNLOAD_CHANNELSallow-list /SLACK_FILE_DOWNLOAD_EXCLUDE_CHANNELSdeny-list pair. A non-integerSLACK_FILE_MAX_BYTESexits 2, following theTWITCH_RATE_LIMIT_*precedent; a value below 1 falls back to the default with a warning.modeistombstoneorhidden_by_limitare refused — theirurl_privateno longer serves bytes, and letting the daemon try only puts a[File download failed]line in the prompt.SCHEMAfields so the dashboard renders them.Credential handling.
url_private_download302s to a login page without the bot token, so the daemon needs it. The adapter declaresheader_rulesfor Slack's own file hosts (files.slack.com,slack-files.com); the daemon'sfetch_headers_for(crates/librefang-channels/src/sidecar.rs) exact-matches the request host against those rules and attaches nothing for any other host._is_slack_file_urlapplies the same pin adapter-side, refusing non-HTTPS URLs and URLs carrying userinfo (https://files.slack.com@evil.example/xresolves toevil.example), so a file registered throughfiles.remote.addwith a member-chosenurl_privateis never forwarded at all. The rules are only declared when forwarding is enabled, soSLACK_FILE_DOWNLOADS=falsedoes not ship the token to the daemon.Why the URL and not the bytes.
download_media_blocksincrates/librefang-channels/src/bridge.rsis what turns an inboundImage/File/Voice/Audio/VideoURL into a vision image block, an audio transcription or a saved document path. Its match has noFileDataarm (_ => Noneat bridge.rs:5873), and the inbound text arm rendersFileDataas[User sent a local file: …](bridge.rs:4412), so an adapter that inlines inbound bytes delivers a placeholder and discards the payload. Forwarding the URL with a host-pinned auth rule is the mechanism the matrix sidecar already uses for MSC3916 media, and it needs no kernel change.Deliberately not followed: link-unfurl
attachments[].image_url. That is preview metadata for a URL somebody pasted, not an upload, and following it would point the daemon at an arbitrary host. Noted in the module docstring.Docs: architecture note on
header_rulesand why inboundFileDatais a dead end (docs/architecture/sidecar-channels.md); a "User Uploads" section, thefiles:readscope and the five env vars in the en and zh channel pages.Changelog fragment:
changelog.d/added/7087-slack-inbound-attachments.md.Verification
cd sdk/python && python3 -m pytest tests -q— 2044 passed (was 2016 before this branch; 28 new cases). This is the exact invocation of CI'sPython SDK Testsjob (.github/workflows/ci.yml,working-directory: sdk/python,python -m pytest tests -q).cd sdk/python && python3 -m pytest tests/test_slack_adapter.py -q— 185 passed.New tests in
sdk/python/tests/test_slack_adapter.py, all against the pureparse_slack_event/parse_slack_filesentry points:test_parse_event_file_share_emits_image_content— afile_shareevent produces{"Image": {url, caption, mime_type}}with routing metadata unchanged.test_parse_event_file_share_video_and_audio_and_document_variants— mimetype dispatch andduration_ms→duration_seconds.test_parse_event_file_share_oversize_is_rejected,test_parse_event_file_share_oversize_keeps_the_companion_text— the size cap, and proof a rejected attachment does not swallow the text the user typed with it.test_parse_event_file_share_disallowed_extension_is_rejected,..._allowed_extension_passes,..._extension_falls_back_to_filetype.test_parse_event_file_share_download_switch_off_drops_the_file,..._per_channel_exclude_list,..._per_channel_allow_list.test_inbound_attachment_parsing_performs_no_http— replaces_public_http_requestand_http_requestwith a function that raises, then parses with the switch on and off: the adapter never fetches an inbound file itself.test_parse_event_file_share_rejects_non_slack_host— an attacker host, a suffix lookalike (files.slack.com.evil.example), the userinfo trick and plain HTTP are all refused.test_header_rules_pin_the_bot_token_to_slack_file_hosts,test_header_rules_absent_when_downloads_are_disabled(asserts the token string appears nowhere in thereadyevent),test_header_rules_surface_in_the_ready_event,test_is_slack_file_url_host_pinning— the token is declared only for Slack file hosts.test_parse_event_file_share_dropped_without_a_policy,test_parse_event_other_subtypes_are_still_dropped,test_parse_event_file_share_still_honours_self_skip_and_channel_filter— no behaviour change for callers that pass no policy, and the existing filters still apply.test_file_policy_defaults,test_file_policy_env_parsing,test_file_max_bytes_non_integer_exits_2,test_file_max_bytes_below_one_falls_back_to_default.No cargo command was run: this branch touches no Rust source.
Left out
ChannelContentper message;Content.media_groupexists but the inbound bridge has noMediaGrouparm indownload_media_blocks, so it would land as a[Media group: N items]placeholder. Extras are logged rather than silently dropped.ChannelContent::Filehas no caption field, soContent.filecannot carry the message. Warned at the boundary, same as the discord sidecar. GivingFilea caption is a change tocrates/librefang-channels/src/types.rsand every adapter that constructs it — a different crate, so it is not folded in here.size(external files sometimes omit it) are accepted; the daemon's ownchannels_download_max_bytesstill bounds the fetch.