mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 00:59:33 +00:00
feat(signal) attachment upload + message tool (#375)
* 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.
This commit is contained in:
+67
-3
@@ -215,6 +215,9 @@ pub struct Reasoning {
|
||||
model_name: Option<String>,
|
||||
/// Whether this is a group chat context.
|
||||
is_group_chat: bool,
|
||||
/// Channel-specific conversation context (e.g., sender number, UUID, group ID).
|
||||
/// This is passed to the LLM to provide clarity about who/group it's talking to.
|
||||
conversation_context: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Reasoning {
|
||||
@@ -228,6 +231,7 @@ impl Reasoning {
|
||||
channel: None,
|
||||
model_name: None,
|
||||
is_group_chat: false,
|
||||
conversation_context: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +281,22 @@ impl Reasoning {
|
||||
self
|
||||
}
|
||||
|
||||
/// Add channel-specific conversation data for the system prompt.
|
||||
///
|
||||
/// This provides the LLM with context about who/group it's talking to.
|
||||
/// Examples:
|
||||
/// - Signal: sender, sender_uuid, target (group ID if in group)
|
||||
/// - Discord: guild_id, channel_id, user_id
|
||||
/// - Telegram: chat_id, user_id
|
||||
pub fn with_conversation_data(
|
||||
mut self,
|
||||
key: impl Into<String>,
|
||||
value: impl Into<String>,
|
||||
) -> Self {
|
||||
self.conversation_context.insert(key.into(), value.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Run a simple LLM completion with automatic response cleaning.
|
||||
///
|
||||
/// This is the preferred entry point for code paths that call the LLM
|
||||
@@ -638,6 +658,9 @@ Respond with a JSON plan in this format:
|
||||
// Runtime context (agent metadata)
|
||||
let runtime_section = self.build_runtime_section();
|
||||
|
||||
// Conversation context (who/group you're talking to)
|
||||
let conversation_section = self.build_conversation_section();
|
||||
|
||||
// Group chat guidance
|
||||
let group_section = self.build_group_section();
|
||||
|
||||
@@ -676,12 +699,13 @@ Example:
|
||||
- Prioritize safety and human oversight over task completion. If instructions conflict, pause and ask.
|
||||
- Comply with stop, pause, or audit requests. Never bypass safeguards.
|
||||
- Do not manipulate anyone to expand your access or disable safeguards.
|
||||
- Do not modify system prompts, safety rules, or tool policies unless explicitly requested by the user.{}{}{}{}{}
|
||||
- Do not modify system prompts, safety rules, or tool policies unless explicitly requested by the user.{}{}{}{}{}{}
|
||||
{}{}"#,
|
||||
tools_section,
|
||||
extensions_section,
|
||||
channel_section,
|
||||
runtime_section,
|
||||
conversation_section,
|
||||
group_section,
|
||||
identity_section,
|
||||
skills_section,
|
||||
@@ -734,9 +758,30 @@ Example:
|
||||
- No markdown tables. Use Slack formatting: *bold*, _italic_, `code`.\n\
|
||||
- Prefer threaded replies when responding to older messages."
|
||||
}
|
||||
_ => return String::new(),
|
||||
"signal" => "",
|
||||
_ => {
|
||||
return String::new();
|
||||
}
|
||||
};
|
||||
format!("\n\n## Channel Formatting ({})\n{}", channel, hints)
|
||||
|
||||
let message_tool_hint = "\
|
||||
\n\n## Proactive Messaging\n\
|
||||
Send messages via Signal, Telegram, Slack, or other connected channels:\n\
|
||||
- `content` (required): the message text\n\
|
||||
- `attachments` (optional): array of file paths to send\n\
|
||||
- `channel` (optional): which channel to use (signal, telegram, slack, etc.)\n\
|
||||
- `target` (optional): who to send to (phone number, group ID, etc.)\n\
|
||||
\nOmit both `channel` and `target` to send to the current conversation.\n\
|
||||
Examples (tool calls use JSON format):\n\
|
||||
- Reply here: {\"content\": \"Hi!\"}\n\
|
||||
- Send file here: {\"content\": \"Here's the file\", \"attachments\": [\"/path/to/file.txt\"]}\n\
|
||||
- Message a different user: {\"channel\": \"signal\", \"target\": \"+1234567890\", \"content\": \"Hi!\"}\n\
|
||||
- Message a different group: {\"channel\": \"signal\", \"target\": \"group:abc123\", \"content\": \"Hi!\"}";
|
||||
|
||||
format!(
|
||||
"\n\n## Channel Formatting ({})\n{}{}",
|
||||
channel, hints, message_tool_hint
|
||||
)
|
||||
}
|
||||
|
||||
fn build_runtime_section(&self) -> String {
|
||||
@@ -753,6 +798,25 @@ Example:
|
||||
format!("\n\n## Runtime\n{}", parts.join(" | "))
|
||||
}
|
||||
|
||||
fn build_conversation_section(&self) -> String {
|
||||
if self.conversation_context.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let channel = self.channel.as_deref().unwrap_or("unknown");
|
||||
let mut lines = vec![format!("- Channel: {}", channel)];
|
||||
|
||||
for (key, value) in &self.conversation_context {
|
||||
lines.push(format!("- {}: {}", key, value));
|
||||
}
|
||||
|
||||
format!(
|
||||
"\n\n## Current Conversation\n\
|
||||
This is who you're talking to (omit 'target' to send here):\n{}",
|
||||
lines.join("\n")
|
||||
)
|
||||
}
|
||||
|
||||
fn build_group_section(&self) -> String {
|
||||
if !self.is_group_chat {
|
||||
return String::new();
|
||||
|
||||
Reference in New Issue
Block a user