mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat(channels/signal): add attachment upload support
- Add attachments field to OutgoingResponse for carrying file paths
- Add with_attachments() builder method to OutgoingResponse
- Update build_rpc_params() to include attachments array in JSON-RPC
- Update respond() and broadcast() to handle attachments:
- Text + attachments: sends text first, then each attachment
- Attachments only: sends each attachment with path as message
- Text only: original behavior (no change)
- Add tests for build_rpc_params with attachments
- Add tests for OutgoingResponse attachment builder
This enables the Signal channel to send files via signal-cli daemon's
JSON-RPC send method, matching the nullclaw implementation.
Risk: Low - uses existing JSON-RPC infrastructure
Tests: 85 signal tests pass, 1543 lib tests pass
* feat(tools): add message tool for cross-channel messaging
Add a new 'message' tool that allows the agent to send messages to
any connected channel (signal, telegram, slack, etc.) with optional
file attachments.
Features:
- Send messages to specific channel + target combinations
- Support for attachments (file paths)
- E.164 validation delegated to channel (signal expects +number,
telegram accepts username/chat_id, slack uses #channels)
- Helpful error messages showing available channels on failure
Tool schema:
- content: message text (required)
- channel: target channel name (optional, defaults to current channel)
- target: recipient (E.164, group ID, chat ID) (optional, defaults to
current user/group chat)
- attachments: optional file paths to send
This complements the recently added attachment upload support for the
Signal channel by giving the agent a proper way to specify attachments
when sending messages.
Tests: 4 new tests for message tool schema
Risk: Low - new tool with no breaking changes
Tests: All 1547 lib tests pass, clippy clean
* feat(llm): add conversation context to system prompt for Signal
Add conversation_context HashMap to Reasoning struct to pass channel-specific
metadata (sender phone, sender UUID, group ID) to the LLM. This helps the
agent know who/group it's talking to, preventing it from hallucinating
phone numbers or sending to wrong recipients.
Changes:
- Add conversation_context field and with_conversation_data() builder method
- Add build_conversation_section() to include current conversation info in system prompt
- Update dispatcher to extract Signal metadata (sender, sender_uuid, group) and pass to Reasoning
- Add signal_sender_uuid to Signal channel metadata for privacy mode users
* feat(tools): add secure attachment path validation with sandbox enforcement
Implement robust path validation for message tool attachments to prevent
directory traversal attacks and unauthorized file access. Attachments are
now sandboxed to ~/.ironclaw/ by default.
Key changes:
- Create shared path_utils module with validate_path() and is_path_safe_basic()
- Extract normalize_lexical() from file.rs for reuse
- MessageTool now enforces sandbox at ~/.ironclaw/ for all attachments
- Path validation includes: traversal detection, canonicalization, symlink resolution
- Error messages reveal the allowed sandbox directory for user clarity
Security improvements:
- Blocks path traversal attacks (../, URL-encoded, null bytes)
- Canonicalizes paths to resolve symlinks before validation
- Walks up to nearest existing ancestor for non-existent paths
- Prevents escape from sandbox directory
Backward compatibility:
- File tools continue to work with their configured base_dir
- Message tool defaults to ~/.ironclaw/ sandbox
- Tests updated to create files within sandbox
Tests added:
- path_utils module tests (9 tests for validation logic)
- message tool attachment validation tests
- All 1571 existing tests pass
* fix(channels/signal): use robust path validation with full security coverage
Signal channel's validate_attachment_paths() now uses path_utils::validate_path()
for consistent, secure path validation.
Fixes:
- Replaced weak path.contains('..') check with robust validate_path()
- validate_path() now includes is_path_safe_basic() as first-pass filter to
block null bytes and URL-encoded traversal sequences (%2e%2e%2f)
- Error message now shows allowed sandbox directory (~/.ironclaw/)
Security coverage:
- Path traversal: ../, foo/../bar, ../../etc/passwd ✓
- URL-encoded traversal: %2e%2e%2fetc/passwd ✓
- Null byte injection: file\0.txt ✓
- Paths outside sandbox: /tmp/evil.txt ✓
- Symlink escape attempts (via canonicalization) ✓
Tests added:
- validate_attachment_paths_rejects_path_outside_sandbox
- validate_attachment_paths_rejects_url_encoded_traversal
- validate_attachment_paths_rejects_null_byte
- Fixed broken assertion in rejects_double_dot test
* fix(llm): add Signal channel to build_channel_section to include message tool hint
The catch-all '_' arm was returning early before the message_tool_hint
section was constructed, which meant Signal users never got the
'## Proactive Messaging' section with examples for:
- Using attachments parameter
- Targeting different users/groups
- Cross-channel messaging
Now Signal will include the full message_tool_hint section with usage examples.
* fix(tools): use async locks in register_message_tools to prevent silent failures
The method was using register_sync which calls try_write() on self.tools.
If the lock was held, try_write() would return Err and silently skip
adding the tool to the registry, while self.message_tool already held
a reference. This creates an inconsistent state.
Fix: use async write locks directly instead of register_sync to ensure
the tool is always registered or the method fails explicitly.
* refactor(dispatcher): use Channel trait for conversation context
Replace hardcoded 'if message.channel == signal' block with generic
conversation_context() method on the Channel trait. This allows any
channel to provide context (sender, group, etc.) without hardcoding
channel names.
Changes:
- Add conversation_context() method to Channel trait (default: empty)
- Implement for SignalChannel: extracts sender, sender_uuid, group
- Add get_channel() to ChannelManager (returns Arc<dyn Channel>)
- Change ChannelManager storage from Box to Arc for shared access
- Update dispatcher to use new trait method
- Add tests for conversation_context extraction
Other channels (Telegram, Slack, Discord) can now implement this
method to provide conversation context without code changes in dispatcher.
* fix(tests): split message_tool_with_attachments into sandbox and channel tests
The original test was passing for the wrong reason - it expected an error
because the channel doesn't exist, but actually failed earlier during sandbox
validation because /tmp paths are outside ~/.ironclaw/.
Split into two tests:
- message_tool_with_attachments_outside_sandbox: verifies sandbox rejection
with explicit error message check
- message_tool_with_attachments_inside_sandbox_no_channel: uses files within
sandbox (like message_tool_passes_attachment_to_broadcast does) and verifies
the channel-related error message
* security(message tool): add rate limiting, approval requirements, and audit logging
The message tool can send to ANY connected channel/target making it a significant
abuse vector if the LLM is compromised or prompt-injected. This commit adds:
1. Rate limiting: 10 messages/minute, 100/hour per user
2. Approval requirement: Always requires approval for cross-channel messages
(when channel differs from the default conversation channel)
3. Audit logging: Every successful message send is logged with channel,
target, and attachment count
The approval logic:
- If channel param is provided and differs from default -> Always require approval
- If no default channel is set and explicit channel provided -> Always require approval
- Otherwise (using default channel) -> UnlessAutoApproved
* fix(message tool): return explicit error for malformed attachments array
Previously, malformed attachments like {"attachments": [123, true]} would be
silently ignored via .ok().unwrap_or_default(), leaving users confused
when attachments weren't sent.
Now returns explicit error: "Invalid attachments format: ..."
* fix(message tool): verify attachment files exist before sending
Previously, non-existent paths would pass sandbox validation and surface
as confusing Signal RPC errors. Now returns clear "Attachment file not found" error.
* fix(test): create sandbox directory if it doesn't exist for CI
The test validate_attachment_paths_accepts_normal_paths uses
tempfile::tempdir_in() which requires the parent directory to exist.
In CI, ~/.ironclaw doesn't exist, causing test failure.
226 lines
6.6 KiB
Rust
226 lines
6.6 KiB
Rust
//! Channel trait and message types.
|
|
|
|
use std::collections::HashMap;
|
|
use std::pin::Pin;
|
|
|
|
use async_trait::async_trait;
|
|
use chrono::{DateTime, Utc};
|
|
use futures::Stream;
|
|
use uuid::Uuid;
|
|
|
|
use crate::error::ChannelError;
|
|
|
|
/// A message received from an external channel.
|
|
#[derive(Debug, Clone)]
|
|
pub struct IncomingMessage {
|
|
/// Unique message ID.
|
|
pub id: Uuid,
|
|
/// Channel this message came from.
|
|
pub channel: String,
|
|
/// User identifier within the channel.
|
|
pub user_id: String,
|
|
/// Optional display name.
|
|
pub user_name: Option<String>,
|
|
/// Message content.
|
|
pub content: String,
|
|
/// Thread/conversation ID for threaded conversations.
|
|
pub thread_id: Option<String>,
|
|
/// When the message was received.
|
|
pub received_at: DateTime<Utc>,
|
|
/// Channel-specific metadata.
|
|
pub metadata: serde_json::Value,
|
|
}
|
|
|
|
impl IncomingMessage {
|
|
/// Create a new incoming message.
|
|
pub fn new(
|
|
channel: impl Into<String>,
|
|
user_id: impl Into<String>,
|
|
content: impl Into<String>,
|
|
) -> Self {
|
|
Self {
|
|
id: Uuid::new_v4(),
|
|
channel: channel.into(),
|
|
user_id: user_id.into(),
|
|
user_name: None,
|
|
content: content.into(),
|
|
thread_id: None,
|
|
received_at: Utc::now(),
|
|
metadata: serde_json::Value::Null,
|
|
}
|
|
}
|
|
|
|
/// Set the thread ID.
|
|
pub fn with_thread(mut self, thread_id: impl Into<String>) -> Self {
|
|
self.thread_id = Some(thread_id.into());
|
|
self
|
|
}
|
|
|
|
/// Set metadata.
|
|
pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
|
|
self.metadata = metadata;
|
|
self
|
|
}
|
|
|
|
/// Set user name.
|
|
pub fn with_user_name(mut self, name: impl Into<String>) -> Self {
|
|
self.user_name = Some(name.into());
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Stream of incoming messages.
|
|
pub type MessageStream = Pin<Box<dyn Stream<Item = IncomingMessage> + Send>>;
|
|
|
|
/// Response to send back to a channel.
|
|
#[derive(Debug, Clone)]
|
|
pub struct OutgoingResponse {
|
|
/// The content to send.
|
|
pub content: String,
|
|
/// Optional thread ID to reply in.
|
|
pub thread_id: Option<String>,
|
|
/// Optional file paths to attach.
|
|
pub attachments: Vec<String>,
|
|
/// Channel-specific metadata for the response.
|
|
pub metadata: serde_json::Value,
|
|
}
|
|
|
|
impl OutgoingResponse {
|
|
/// Create a simple text response.
|
|
pub fn text(content: impl Into<String>) -> Self {
|
|
Self {
|
|
content: content.into(),
|
|
thread_id: None,
|
|
attachments: Vec::new(),
|
|
metadata: serde_json::Value::Null,
|
|
}
|
|
}
|
|
|
|
/// Set the thread ID for the response.
|
|
pub fn in_thread(mut self, thread_id: impl Into<String>) -> Self {
|
|
self.thread_id = Some(thread_id.into());
|
|
self
|
|
}
|
|
|
|
/// Add attachments to the response.
|
|
pub fn with_attachments(mut self, paths: Vec<String>) -> Self {
|
|
self.attachments = paths;
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Status update types for showing agent activity.
|
|
#[derive(Debug, Clone)]
|
|
pub enum StatusUpdate {
|
|
/// Agent is thinking/processing.
|
|
Thinking(String),
|
|
/// Tool execution started.
|
|
ToolStarted { name: String },
|
|
/// Tool execution completed.
|
|
ToolCompleted { name: String, success: bool },
|
|
/// Brief preview of tool execution output.
|
|
ToolResult { name: String, preview: String },
|
|
/// Streaming text chunk.
|
|
StreamChunk(String),
|
|
/// General status message.
|
|
Status(String),
|
|
/// A sandbox job has started (shown as a clickable card in the UI).
|
|
JobStarted {
|
|
job_id: String,
|
|
title: String,
|
|
browse_url: String,
|
|
},
|
|
/// Tool requires user approval before execution.
|
|
ApprovalNeeded {
|
|
request_id: String,
|
|
tool_name: String,
|
|
description: String,
|
|
parameters: serde_json::Value,
|
|
},
|
|
/// Extension needs user authentication (token or OAuth).
|
|
AuthRequired {
|
|
extension_name: String,
|
|
instructions: Option<String>,
|
|
auth_url: Option<String>,
|
|
setup_url: Option<String>,
|
|
},
|
|
/// Extension authentication completed.
|
|
AuthCompleted {
|
|
extension_name: String,
|
|
success: bool,
|
|
message: String,
|
|
},
|
|
}
|
|
|
|
/// Trait for message channels.
|
|
///
|
|
/// Channels receive messages from external sources and convert them to
|
|
/// a unified format. They also handle sending responses back.
|
|
#[async_trait]
|
|
pub trait Channel: Send + Sync {
|
|
/// Get the channel name (e.g., "cli", "slack", "telegram", "http").
|
|
fn name(&self) -> &str;
|
|
|
|
/// Start listening for messages.
|
|
///
|
|
/// Returns a stream of incoming messages. The channel should handle
|
|
/// reconnection and error recovery internally.
|
|
async fn start(&self) -> Result<MessageStream, ChannelError>;
|
|
|
|
/// Send a response back to the user.
|
|
///
|
|
/// The response is sent in the context of the original message
|
|
/// (same channel, same thread if applicable).
|
|
async fn respond(
|
|
&self,
|
|
msg: &IncomingMessage,
|
|
response: OutgoingResponse,
|
|
) -> Result<(), ChannelError>;
|
|
|
|
/// Send a status update (thinking, tool execution, etc.).
|
|
///
|
|
/// The metadata contains channel-specific routing info (e.g., Telegram chat_id)
|
|
/// needed to deliver the status to the correct destination.
|
|
///
|
|
/// Default implementation does nothing (for channels that don't support status).
|
|
async fn send_status(
|
|
&self,
|
|
_status: StatusUpdate,
|
|
_metadata: &serde_json::Value,
|
|
) -> Result<(), ChannelError> {
|
|
Ok(())
|
|
}
|
|
|
|
/// Send a proactive message without a prior incoming message.
|
|
///
|
|
/// Used for alerts, heartbeat notifications, and other agent-initiated communication.
|
|
/// The user_id helps target a specific user within the channel.
|
|
///
|
|
/// Default implementation does nothing (for channels that don't support broadcast).
|
|
async fn broadcast(
|
|
&self,
|
|
_user_id: &str,
|
|
_response: OutgoingResponse,
|
|
) -> Result<(), ChannelError> {
|
|
Ok(())
|
|
}
|
|
|
|
/// Check if the channel is healthy.
|
|
async fn health_check(&self) -> Result<(), ChannelError>;
|
|
|
|
/// Get conversation context from message metadata for system prompt.
|
|
///
|
|
/// Returns key-value pairs like "sender", "sender_uuid", "group" that
|
|
/// help the LLM understand who it's talking to.
|
|
///
|
|
/// Default implementation returns empty map.
|
|
fn conversation_context(&self, _metadata: &serde_json::Value) -> HashMap<String, String> {
|
|
HashMap::new()
|
|
}
|
|
|
|
/// Gracefully shut down the channel.
|
|
async fn shutdown(&self) -> Result<(), ChannelError> {
|
|
Ok(())
|
|
}
|
|
}
|