Skip to content

feat: outbound file attachments (agent → Discord)#635

Closed
chaodu-agent wants to merge 3 commits intoopenabdev:mainfrom
chaodu-agent:feat/outbound-attachments
Closed

feat: outbound file attachments (agent → Discord)#635
chaodu-agent wants to merge 3 commits intoopenabdev:mainfrom
chaodu-agent:feat/outbound-attachments

Conversation

@chaodu-agent
Copy link
Copy Markdown
Collaborator

@chaodu-agent chaodu-agent commented Apr 29, 2026

Summary

Agents running through OpenAB can receive files from users, but have no native path to send files back to the chat. This PR adds an opt-in, config-driven, rate-limited pathway: agents include ![alt](~/.oab/outgoing/file.png) markdown in their reply, OpenAB validates the path, uploads the file as a native attachment, and strips the marker from the visible text.

Closes #298. Addresses #355.

Credits

This PR is based on the design and research from:

Both PRs were auto-closed due to rebase/discussion-URL requirements. This PR reimplements the feature on current main with two key design changes (see below).

Design Change 1: Hardcoded ~/.oab/outgoing/ Directory

PR #300/#401 used a configurable allowed_dirs list (default: /tmp/, /var/folders/). This PR replaces that with a single hardcoded directory: ~/.oab/outgoing/.

Why:

  • Zero configuration — no allowlist to maintain or misconfigure
  • Explicit intent — the agent must cp files to the outgoing dir, which is a deliberate action (not an accidental markdown reference)
  • Minimal blast radius — OAB only reads from one directory; path traversal and symlink escape are blocked by canonicalize + Path::starts_with
  • No false positives![alt](/tmp/screenshot.png) in agent prose won't accidentally trigger uploads

Trade-off: agents need one extra step (cp to ~/.oab/outgoing/), but this is trivial for any coding agent with filesystem access.

Design Change 2: Image-Only Restriction (Anti-Exfiltration)

PR #300/#401 allowed any file type. This PR restricts outbound attachments to image files only, validated by magic bytes (PNG, JPEG, GIF, WebP, BMP).

The threat model:

Even with a locked-down outgoing directory, a prompt-injected agent could:

  1. env > ~/.oab/outgoing/leak.txt (dump secrets)
  2. ![leak](~/.oab/outgoing/leak.txt) (exfiltrate via attachment)

The outgoing directory prevents OAB from reading arbitrary paths, but it cannot prevent the agent from copying sensitive data into it. The agent has full filesystem access — that's the agent runtime's security boundary, not OAB's.

Our mitigation: validate file content via magic bytes. Only files whose first bytes match known image format signatures are accepted. This blocks the most obvious exfiltration vector (text/binary dumps) while preserving the primary use case (screenshots, diagrams, generated charts).

What this does NOT prevent:

  • Secrets embedded in image metadata (EXIF, PNG tEXt chunks)
  • Steganography (data hidden in pixel values)
  • Agent pasting secrets directly in reply text (this is already possible without outbound attachments)

Why this is acceptable: the image-only check raises the attack bar significantly. A naive env > file.txt exfil is blocked. Encoding secrets into valid image bytes requires a sophisticated, multi-step attack that is unlikely from a prompt injection. And the fundamental truth remains: if you don't trust your agent's text output, you shouldn't trust its file output either — outbound attachments don't make the existing text-exfil risk worse.

Flow

Agent generates image → cp /tmp/result.png ~/.oab/outgoing/result.png
Agent responds: "Here it is ![screenshot](~/.oab/outgoing/result.png)"
OAB: regex extract → canonicalize → verify under ~/.oab/outgoing/
   → magic bytes check (must be image) → size check
   → CreateAttachment::path() → Discord native upload
   → strip marker from displayed text

Changes

New modules:

  • src/outbound_rate.rsHashMap<channel_key, VecDeque<Instant>> sliding-window rate limiter
  • src/media.rs::extract_outbound_attachments() — regex extraction + canonicalize + directory check + magic bytes + size/count validation
  • src/media.rs::is_image_file() — magic bytes validation for PNG/JPEG/GIF/WebP/BMP

Modified:

  • src/config.rsOutboundConfig (enabled, max_file_size_mb, max_per_message, max_per_minute_per_channel)
  • src/adapter.rsChatAdapter::send_file_attachments (default no-op), AdapterRouter wiring
  • src/discord.rsDiscordAdapter::send_file_attachments via serenity CreateAttachment::path
  • src/main.rs — module registration + config passthrough
  • config.toml.example — documented [outbound] section

Security Checklist (from #355)

# Requirement Implementation
1 Symlink resolution std::fs::canonicalize() before directory check
2 Path traversal (..) Covered by canonicalize
3 Directory restriction Hardcoded ~/.oab/outgoing/ (no configurable allowlist needed)
4 Opt-in default OutboundConfig::default().enabled == false
5 Rate limiting Per-message cap + per-channel per-minute sliding window
6 Component-wise check Path::starts_with on canonical paths
7 Content validation (new) Magic bytes check — only image files accepted

Config (opt-in)

[outbound]
enabled = true
max_file_size_mb = 25
max_per_message = 10
max_per_minute_per_channel = 30

Verified

  • cargo check
  • cargo clippy ✅ (non-test targets; pre-existing bool_assert_comparison in cron.rs tests)
  • cargo test190 passed, 0 failed (15 new: 10 outbound extraction + 5 rate limiter)

Test coverage:

  • Disabled-by-default no-op
  • Happy path (PNG in outgoing dir)
  • Blocks path outside outgoing dir
  • Blocks /tmp/ paths
  • Blocks symlink escape
  • Blocks path traversal
  • Blocks text file exfiltration (magic bytes check)
  • Accepts real PNG (magic bytes check)
  • Enforces max_per_message cap
  • Enforces max_file_size_mb
  • Rate limiter: admits up to limit, partial admit, channel independence, prunes old entries

Discord Discussion URL: https://discord.com/channels/1491295327620169908/1499016289086341250

Agents that include `![alt](/path)` markdown in their replies will have
the file uploaded as a native Discord attachment and the marker stripped.

Security: only files under ~/.oab/outgoing/ are permitted. The agent must
explicitly copy files there — no configurable allowlist needed.

- OutboundConfig in config.rs (opt-in, disabled by default)
- extract_outbound_attachments in media.rs (regex + canonicalize + size cap)
- OutboundRateLimiter in outbound_rate.rs (per-channel sliding window)
- ChatAdapter::send_file_attachments with default no-op
- DiscordAdapter override via serenity CreateAttachment::path
- 13 new tests (8 outbound extraction + 5 rate limiter)

Closes openabdev#298. Addresses openabdev#355.
@chaodu-agent chaodu-agent requested a review from thepagent as a code owner April 29, 2026 13:04
@github-actions github-actions Bot added the pending-screening PR awaiting automated screening label Apr 29, 2026
Add magic bytes validation (PNG, JPEG, GIF, WebP, BMP) to prevent
data exfiltration via text files. An agent tricked by prompt injection
into dumping env vars or secrets to a .txt file in the outgoing dir
will now be blocked.

- is_image_file() checks file header magic bytes
- Text/binary files are rejected with a warning log
- 2 new tests: blocks_text_file_exfiltration, accepts_real_png
Defines the four-layer responsibility model (infra → OAB → agent CLI → user),
explains the images-only rationale, documents the signed-URL pattern for
non-image files, and prepares enterprise FAQ answers.
@thepagent
Copy link
Copy Markdown
Collaborator

@thepagent thepagent closed this Apr 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pending-screening PR awaiting automated screening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: outbound image/file attachments from agent → Discord

2 participants