Skip to content
Open
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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,20 @@ stall_soft_ms = 10000
stall_hard_ms = 30000
done_hold_ms = 1500
error_hold_ms = 2500

# Outbound file attachments — when enabled, agents that include
# `![alt](/path/to/file)` markdown in their replies will have the file
# uploaded as a native chat attachment and the marker stripped from the
# displayed text.
#
# This feature is opt-in because it opens a path from the host filesystem
# to the chat channel. Enable only after verifying your deployment's
# threat model accepts it. See openabdev/openab#355 for the security
# rationale.
#
# [outbound]
# enabled = false
# allowed_dirs = ["/tmp/", "/var/folders/"] # canonicalized at send time
# max_file_size_mb = 25 # 25 MB matches Discord's limit
# max_per_message = 10 # per agent response
# max_per_minute_per_channel = 30 # sliding window flood guard
65 changes: 63 additions & 2 deletions src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ use tokio::sync::watch;
use tracing::error;

use crate::acp::{classify_notification, AcpEvent, ContentBlock, SessionPool};
use crate::config::ReactionsConfig;
use crate::config::{OutboundConfig, ReactionsConfig};
use crate::error_display::{format_coded_error, format_user_error};
use crate::format;
use crate::outbound_rate::OutboundRateLimiter;
use crate::reactions::StatusReactionController;

// --- Platform-agnostic types ---
Expand Down Expand Up @@ -73,6 +74,18 @@ pub trait ChatAdapter: Send + Sync + 'static {

/// Remove a reaction/emoji from a message.
async fn remove_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()>;

/// Upload file attachments as follow-up messages in `channel`. Called
/// after the final text edit when the agent produced valid
/// `![alt](/path)` markers. Default no-op so adapters that don't support
/// native file upload silently drop the list instead of erroring.
async fn send_file_attachments(
&self,
_channel: &ChannelRef,
_paths: &[std::path::PathBuf],
) -> Result<()> {
Ok(())
}
}

// --- AdapterRouter ---
Expand All @@ -82,13 +95,21 @@ pub trait ChatAdapter: Send + Sync + 'static {
pub struct AdapterRouter {
pool: Arc<SessionPool>,
reactions_config: ReactionsConfig,
outbound_config: OutboundConfig,
outbound_rate: Arc<OutboundRateLimiter>,
}

impl AdapterRouter {
pub fn new(pool: Arc<SessionPool>, reactions_config: ReactionsConfig) -> Self {
pub fn new(
pool: Arc<SessionPool>,
reactions_config: ReactionsConfig,
outbound_config: OutboundConfig,
) -> Self {
Self {
pool,
reactions_config,
outbound_config,
outbound_rate: Arc::new(OutboundRateLimiter::new()),
}
}

Expand Down Expand Up @@ -210,6 +231,10 @@ impl AdapterRouter {
let thread_channel = thread_channel.clone();
let msg_ref = thinking_msg.clone();
let message_limit = adapter.message_limit();
let outbound_cfg = self.outbound_config.clone();
let outbound_rate = Arc::clone(&self.outbound_rate);
let outbound_channel_key =
format!("{}:{}", adapter.platform(), &thread_channel.channel_id);

self.pool
.with_connection(thread_key, |conn| {
Expand Down Expand Up @@ -355,6 +380,33 @@ impl AdapterRouter {
final_content
};

// Extract outbound `![alt](/path)` attachment markers
// from the agent's reply. No-op when `outbound.enabled`
// is false (the default). See src/media.rs for the
// canonicalization + allowlist + size-cap validation.
let (final_content, mut outbound_paths) =
crate::media::extract_outbound_attachments(&final_content, &outbound_cfg);

// Per-channel sliding-window rate limit. Drops any
// excess beyond `max_per_minute_per_channel`.
if !outbound_paths.is_empty() && outbound_cfg.enabled {
let grant = outbound_rate.admit(
&outbound_channel_key,
outbound_paths.len(),
outbound_cfg.max_per_minute_per_channel,
);
if grant < outbound_paths.len() {
tracing::warn!(
channel = outbound_channel_key,
requested = outbound_paths.len(),
granted = grant,
limit_per_min = outbound_cfg.max_per_minute_per_channel,
"outbound: rate-limit hit, dropping excess"
);
outbound_paths.truncate(grant);
}
}

let chunks = format::split_message(&final_content, message_limit);
let mut current_msg = msg_ref;
for (i, chunk) in chunks.iter().enumerate() {
Expand All @@ -367,6 +419,15 @@ impl AdapterRouter {
}
}

if !outbound_paths.is_empty() {
if let Err(e) = adapter
.send_file_attachments(&thread_channel, &outbound_paths)
.await
{
tracing::warn!(error = %e, "outbound: send_file_attachments failed");
}
}

Ok(())
})
})
Expand Down
71 changes: 71 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,77 @@ pub struct Config {
pub reactions: ReactionsConfig,
#[serde(default)]
pub stt: SttConfig,
#[serde(default)]
pub outbound: OutboundConfig,
}

/// Controls outbound file attachments — the `![alt](/path)` markdown marker
/// in agent responses that instructs the bot to upload a local file as a
/// native chat attachment. Disabled by default; operators must explicitly
/// opt in because this opens a path from the host filesystem to the chat
/// channel.
///
/// See openabdev/openab#298 for the feature rationale and openabdev/openab#355
/// for the security requirements this config implements.
#[derive(Debug, Clone, Deserialize)]
pub struct OutboundConfig {
/// Master switch. Defaults to `false` so shipping this feature cannot
/// surprise existing deployments.
#[serde(default)]
pub enabled: bool,
/// Directories from which agents may send files. An outbound path must
/// canonicalize (symlinks + `..` resolved) to live under one of these
/// prefixes. Defaults to `["/tmp/", "/var/folders/"]` to preserve
/// behavior for operators upgrading from the prior hard-coded list.
#[serde(default = "default_outbound_allowed_dirs")]
pub allowed_dirs: Vec<String>,
/// Cap on file size per attachment, in megabytes. Discord's native
/// upload limit is 25 MB; Slack is 1 GB. Default matches Discord so
/// the feature is platform-safe out of the box.
#[serde(default = "default_outbound_max_size_mb")]
pub max_file_size_mb: u64,
/// Cap on attachments per single agent response. Guards against a single
/// agent message fanning out into hundreds of uploads.
#[serde(default = "default_outbound_max_per_message")]
pub max_per_message: usize,
/// Sliding-window cap on attachments per channel per minute. Guards
/// against a malfunctioning agent flooding a channel.
#[serde(default = "default_outbound_max_per_minute")]
pub max_per_minute_per_channel: usize,
}

impl Default for OutboundConfig {
fn default() -> Self {
Self {
enabled: false,
allowed_dirs: default_outbound_allowed_dirs(),
max_file_size_mb: default_outbound_max_size_mb(),
max_per_message: default_outbound_max_per_message(),
max_per_minute_per_channel: default_outbound_max_per_minute(),
}
}
}

impl OutboundConfig {
/// Return the size cap as bytes for internal comparison. Config is
/// expressed in MB for human ergonomics; callers that need to compare
/// against `std::fs::Metadata::len()` use this.
pub fn max_size_bytes(&self) -> u64 {
self.max_file_size_mb.saturating_mul(1024 * 1024)
}
}

fn default_outbound_allowed_dirs() -> Vec<String> {
vec!["/tmp/".into(), "/var/folders/".into()]
}
fn default_outbound_max_size_mb() -> u64 {
25
}
fn default_outbound_max_per_message() -> usize {
10
}
fn default_outbound_max_per_minute() -> usize {
30
}

#[derive(Debug, Clone, Deserialize)]
Expand Down
26 changes: 25 additions & 1 deletion src/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use crate::format;
use crate::media;
use async_trait::async_trait;
use std::sync::LazyLock;
use serenity::builder::{CreateThread, EditMessage};
use serenity::builder::{CreateAttachment, CreateMessage, CreateThread, EditMessage};
use serenity::http::Http;
use serenity::model::channel::{AutoArchiveDuration, Message, ReactionType};
use serenity::model::gateway::Ready;
Expand Down Expand Up @@ -112,6 +112,30 @@ impl ChatAdapter for DiscordAdapter {
.await?;
Ok(())
}

async fn send_file_attachments(
&self,
channel: &ChannelRef,
paths: &[std::path::PathBuf],
) -> anyhow::Result<()> {
let ch_id: u64 = channel.channel_id.parse()?;
for path in paths {
match CreateAttachment::path(path).await {
Ok(file) => {
let msg = CreateMessage::new().add_file(file);
if let Err(e) = ChannelId::new(ch_id).send_message(&self.http, msg).await {
tracing::warn!(path = %path.display(), error = %e, "outbound: discord upload failed");
} else {
info!(path = %path.display(), "outbound: attachment sent");
}
}
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "outbound: failed to read file");
}
}
}
Ok(())
}
}

// --- Handler: serenity EventHandler that delegates to AdapterRouter ---
Expand Down
7 changes: 6 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod discord;
mod error_display;
mod format;
mod media;
mod outbound_rate;
mod reactions;
mod setup;
mod slack;
Expand Down Expand Up @@ -95,7 +96,11 @@ async fn main() -> anyhow::Result<()> {
info!(model = %cfg.stt.model, base_url = %cfg.stt.base_url, "STT enabled");
}

let router = Arc::new(AdapterRouter::new(pool.clone(), cfg.reactions));
let router = Arc::new(AdapterRouter::new(
pool.clone(),
cfg.reactions,
cfg.outbound,
));

// Shutdown signal for Slack adapter
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
Expand Down
Loading
Loading