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:
ibhagwan
2026-02-26 18:06:21 +04:00
committed by GitHub
parent 1156884a49
commit bf35b59222
14 changed files with 1256 additions and 119 deletions
+20
View File
@@ -1,5 +1,6 @@
//! Channel trait and message types.
use std::collections::HashMap;
use std::pin::Pin;
use async_trait::async_trait;
@@ -78,6 +79,8 @@ pub struct OutgoingResponse {
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,
}
@@ -88,6 +91,7 @@ impl OutgoingResponse {
Self {
content: content.into(),
thread_id: None,
attachments: Vec::new(),
metadata: serde_json::Value::Null,
}
}
@@ -97,6 +101,12 @@ impl OutgoingResponse {
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.
@@ -198,6 +208,16 @@ pub trait Channel: Send + Sync {
/// 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(())
+14 -3
View File
@@ -14,7 +14,7 @@ use crate::error::ChannelError;
/// Includes an injection channel so background tasks (e.g., job monitors) can
/// push messages into the agent loop without being a full `Channel` impl.
pub struct ChannelManager {
channels: Arc<RwLock<HashMap<String, Box<dyn Channel>>>>,
channels: Arc<RwLock<HashMap<String, Arc<dyn Channel>>>>,
inject_tx: mpsc::Sender<IncomingMessage>,
/// Taken once in `start_all()` and merged into the stream.
inject_rx: tokio::sync::Mutex<Option<mpsc::Receiver<IncomingMessage>>>,
@@ -42,7 +42,10 @@ impl ChannelManager {
/// Add a channel to the manager.
pub async fn add(&self, channel: Box<dyn Channel>) {
let name = channel.name().to_string();
self.channels.write().await.insert(name.clone(), channel);
self.channels
.write()
.await
.insert(name.clone(), Arc::from(channel));
tracing::debug!("Added channel: {}", name);
}
@@ -56,7 +59,10 @@ impl ChannelManager {
let stream = channel.start().await?;
// Register for respond/broadcast/send_status
self.channels.write().await.insert(name.clone(), channel);
self.channels
.write()
.await
.insert(name.clone(), Arc::from(channel));
// Forward stream messages through inject_tx
let tx = self.inject_tx.clone();
@@ -217,6 +223,11 @@ impl ChannelManager {
pub async fn channel_names(&self) -> Vec<String> {
self.channels.read().await.keys().cloned().collect()
}
/// Get a channel by name.
pub async fn get_channel(&self, name: &str) -> Option<Arc<dyn Channel>> {
self.channels.read().await.get(name).cloned()
}
}
impl Default for ChannelManager {
+331 -15
View File
@@ -244,7 +244,7 @@ impl SignalChannel {
.map_err(|e| ChannelError::Http(e.to_string()))?;
let target = Self::parse_recipient_target(recipient);
let params = Self::build_rpc_params_static(http_url, account, &target, Some(message));
let params = Self::build_rpc_params_static(http_url, account, &target, Some(message), None);
let url = format!("{}/api/v1/rpc", http_url);
let id = Uuid::new_v4().to_string();
@@ -504,6 +504,7 @@ impl SignalChannel {
&self,
target: &RecipientTarget,
message: Option<&str>,
attachments: Option<&[String]>,
) -> serde_json::Value {
match target {
RecipientTarget::Direct(id) => {
@@ -514,6 +515,16 @@ impl SignalChannel {
if let Some(msg) = message {
params["message"] = serde_json::Value::String(msg.to_string());
}
if let Some(attachments) = attachments
&& !attachments.is_empty()
{
params["attachments"] = serde_json::Value::Array(
attachments
.iter()
.map(|s| serde_json::Value::String(s.clone()))
.collect(),
);
}
params
}
RecipientTarget::Group(group_id) => {
@@ -524,17 +535,78 @@ impl SignalChannel {
if let Some(msg) = message {
params["message"] = serde_json::Value::String(msg.to_string());
}
if let Some(attachments) = attachments
&& !attachments.is_empty()
{
params["attachments"] = serde_json::Value::Array(
attachments
.iter()
.map(|s| serde_json::Value::String(s.clone()))
.collect(),
);
}
params
}
}
}
/// Validate that attachment paths are safe and within the sandbox.
/// Uses the shared path validation logic from path_utils to ensure:
/// - No path traversal attacks (../, URL-encoded, null bytes)
/// - Paths are canonicalized and symlinks resolved
/// - All paths are within ~/.ironclaw/ sandbox
fn validate_attachment_paths(paths: &[String]) -> Result<(), ChannelError> {
// Get the sandbox base directory (same as MessageTool uses)
let base_dir = dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".ironclaw");
for path in paths {
crate::tools::builtin::path_utils::validate_path(path, Some(&base_dir)).map_err(
|e| {
ChannelError::InvalidMessage(format!(
"Attachment path must be within {}: {}",
base_dir.display(),
e
))
},
)?;
}
Ok(())
}
/// Send a message with attachments (if any).
/// Combines text and attachments into a single RPC call when both are present.
async fn send_with_attachments(
&self,
target: &RecipientTarget,
content: &str,
attachments: &[String],
) -> Result<(), ChannelError> {
Self::validate_attachment_paths(attachments)?;
if attachments.is_empty() {
let params = self.build_rpc_params(target, Some(content), None);
self.rpc_request("send", params).await?;
} else if content.is_empty() {
// Attachments only - send all in a single call with no message text
let params = self.build_rpc_params(target, None, Some(attachments));
self.rpc_request("send", params).await?;
} else {
// Both text and attachments - send in a single RPC call
let params = self.build_rpc_params(target, Some(content), Some(attachments));
self.rpc_request("send", params).await?;
}
Ok(())
}
/// Build JSON-RPC params for a send/typing call (static version).
fn build_rpc_params_static(
_http_url: &str,
account: &str,
target: &RecipientTarget,
message: Option<&str>,
attachments: Option<&[String]>,
) -> serde_json::Value {
match target {
RecipientTarget::Direct(id) => {
@@ -545,6 +617,16 @@ impl SignalChannel {
if let Some(msg) = message {
params["message"] = serde_json::Value::String(msg.to_string());
}
if let Some(attachments) = attachments
&& !attachments.is_empty()
{
params["attachments"] = serde_json::Value::Array(
attachments
.iter()
.map(|s| serde_json::Value::String(s.clone()))
.collect(),
);
}
params
}
RecipientTarget::Group(group_id) => {
@@ -555,6 +637,16 @@ impl SignalChannel {
if let Some(msg) = message {
params["message"] = serde_json::Value::String(msg.to_string());
}
if let Some(attachments) = attachments
&& !attachments.is_empty()
{
params["attachments"] = serde_json::Value::Array(
attachments
.iter()
.map(|s| serde_json::Value::String(s.clone()))
.collect(),
);
}
params
}
}
@@ -706,8 +798,10 @@ impl SignalChannel {
});
// Build metadata with signal-specific routing info.
let sender_uuid = envelope.source_uuid.as_deref();
let metadata = serde_json::json!({
"signal_sender": &sender,
"signal_sender_uuid": sender_uuid,
"signal_target": &target,
"signal_timestamp": timestamp,
});
@@ -790,13 +884,16 @@ impl Channel for SignalChannel {
.unwrap_or_else(|| msg.user_id.clone());
let target = Self::parse_recipient_target(&target_str);
let params = self.build_rpc_params(&target, Some(&response.content));
self.rpc_request("send", params).await?;
// Clean up stored target.
// Use shared helper for sending with attachments (includes validation)
let result = self
.send_with_attachments(&target, &response.content, &response.attachments)
.await;
// Clean up stored target regardless of success or failure.
self.reply_targets.write().await.pop(&msg.id);
Ok(())
result
}
async fn send_status(
@@ -809,7 +906,7 @@ impl Channel for SignalChannel {
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
{
let target = Self::parse_recipient_target(target_str);
let params = self.build_rpc_params(&target, None);
let params = self.build_rpc_params(&target, None, None);
let _ = self.rpc_request("sendTyping", params).await;
}
@@ -957,9 +1054,10 @@ impl Channel for SignalChannel {
response: OutgoingResponse,
) -> Result<(), ChannelError> {
let target = Self::parse_recipient_target(user_id);
let params = self.build_rpc_params(&target, Some(&response.content));
self.rpc_request("send", params).await?;
Ok(())
// Use shared helper for sending with attachments (includes validation)
self.send_with_attachments(&target, &response.content, &response.attachments)
.await
}
async fn health_check(&self) -> Result<(), ChannelError> {
@@ -982,12 +1080,34 @@ impl Channel for SignalChannel {
})
}
}
fn conversation_context(
&self,
metadata: &serde_json::Value,
) -> std::collections::HashMap<String, String> {
use std::collections::HashMap;
let mut ctx = HashMap::new();
if let Some(sender) = metadata.get("signal_sender").and_then(|v| v.as_str()) {
ctx.insert("sender".to_string(), sender.to_string());
}
if let Some(sender_uuid) = metadata.get("signal_sender_uuid").and_then(|v| v.as_str()) {
ctx.insert("sender_uuid".to_string(), sender_uuid.to_string());
}
if let Some(target) = metadata.get("signal_target").and_then(|v| v.as_str())
&& target.starts_with("group:")
{
ctx.insert("group".to_string(), target.to_string());
}
ctx
}
}
impl SignalChannel {
async fn send_status_message(&self, target: &str, message: &str) {
let target = Self::parse_recipient_target(target);
let params = self.build_rpc_params(&target, Some(message));
let params = self.build_rpc_params(&target, Some(message), None);
if let Err(e) = self.rpc_request("send", params).await {
tracing::warn!("Signal: failed to send status message: {}", e);
}
@@ -1187,6 +1307,7 @@ async fn sse_listener(
let reply_params = channel.build_rpc_params(
&SignalChannel::parse_recipient_target(&target),
Some(response),
None,
);
let _ = channel.rpc_request("send", reply_params).await;
// Don't send the /debug command to the agent.
@@ -1925,7 +2046,7 @@ mod tests {
fn build_rpc_params_direct_with_message() -> Result<(), ChannelError> {
let ch = make_channel()?;
let target = RecipientTarget::Direct("+5555555555".to_string());
let params = ch.build_rpc_params(&target, Some("Hello!"));
let params = ch.build_rpc_params(&target, Some("Hello!"), None);
assert_eq!(params["recipient"], serde_json::json!(["+5555555555"]));
assert_eq!(params["account"], "+1234567890");
assert_eq!(params["message"], "Hello!");
@@ -1938,7 +2059,7 @@ mod tests {
fn build_rpc_params_direct_without_message() -> Result<(), ChannelError> {
let ch = make_channel()?;
let target = RecipientTarget::Direct("+5555555555".to_string());
let params = ch.build_rpc_params(&target, None);
let params = ch.build_rpc_params(&target, None, None);
assert_eq!(params["recipient"], serde_json::json!(["+5555555555"]));
assert_eq!(params["account"], "+1234567890");
// No message key should be present for typing indicators.
@@ -1950,7 +2071,7 @@ mod tests {
fn build_rpc_params_group_with_message() -> Result<(), ChannelError> {
let ch = make_channel()?;
let target = RecipientTarget::Group("abc123".to_string());
let params = ch.build_rpc_params(&target, Some("Group msg"));
let params = ch.build_rpc_params(&target, Some("Group msg"), None);
assert_eq!(params["groupId"], "abc123");
assert_eq!(params["account"], "+1234567890");
assert_eq!(params["message"], "Group msg");
@@ -1963,7 +2084,7 @@ mod tests {
fn build_rpc_params_group_without_message() -> Result<(), ChannelError> {
let ch = make_channel()?;
let target = RecipientTarget::Group("abc123".to_string());
let params = ch.build_rpc_params(&target, None);
let params = ch.build_rpc_params(&target, None, None);
assert_eq!(params["groupId"], "abc123");
assert_eq!(params["account"], "+1234567890");
assert!(params.get("message").is_none());
@@ -1975,11 +2096,94 @@ mod tests {
let ch = make_channel()?;
let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
let target = RecipientTarget::Direct(uuid.to_string());
let params = ch.build_rpc_params(&target, Some("hi"));
let params = ch.build_rpc_params(&target, Some("hi"), None);
assert_eq!(params["recipient"], serde_json::json!([uuid]));
Ok(())
}
// ── build_rpc_params with attachments tests ─────────────────────────
#[test]
fn build_rpc_params_with_attachments() -> Result<(), ChannelError> {
let ch = make_channel()?;
let target = RecipientTarget::Direct("+5555555555".to_string());
let attachments = vec!["/path/to/image.png".to_string()];
let params = ch.build_rpc_params(&target, Some("Check this!"), Some(&attachments));
assert_eq!(params["recipient"], serde_json::json!(["+5555555555"]));
assert_eq!(params["message"], "Check this!");
assert_eq!(
params["attachments"],
serde_json::json!(["/path/to/image.png"])
);
Ok(())
}
#[test]
fn build_rpc_params_with_multiple_attachments() -> Result<(), ChannelError> {
let ch = make_channel()?;
let target = RecipientTarget::Direct("+5555555555".to_string());
let attachments = vec![
"/path/to/image.png".to_string(),
"/path/to/document.pdf".to_string(),
];
let params = ch.build_rpc_params(&target, Some("Files attached"), Some(&attachments));
assert_eq!(
params["attachments"],
serde_json::json!(["/path/to/image.png", "/path/to/document.pdf"])
);
Ok(())
}
#[test]
fn build_rpc_params_with_attachments_no_message() -> Result<(), ChannelError> {
let ch = make_channel()?;
let target = RecipientTarget::Direct("+5555555555".to_string());
let attachments = vec!["/path/to/image.png".to_string()];
let params = ch.build_rpc_params(&target, None, Some(&attachments));
assert!(params.get("message").is_none());
assert_eq!(
params["attachments"],
serde_json::json!(["/path/to/image.png"])
);
Ok(())
}
#[test]
fn build_rpc_params_group_with_attachments() -> Result<(), ChannelError> {
let ch = make_channel()?;
let target = RecipientTarget::Group("abc123".to_string());
let attachments = vec!["/path/to/photo.jpg".to_string()];
let params = ch.build_rpc_params(&target, Some("Group photo"), Some(&attachments));
assert_eq!(params["groupId"], "abc123");
assert_eq!(params["message"], "Group photo");
assert_eq!(
params["attachments"],
serde_json::json!(["/path/to/photo.jpg"])
);
Ok(())
}
// ── OutgoingResponse attachment tests ─────────────────────────────
#[test]
fn outgoing_response_with_attachments() {
let response = OutgoingResponse::text("Hello with file")
.with_attachments(vec!["/path/to/file.png".to_string()]);
assert_eq!(response.content, "Hello with file");
assert!(
response
.attachments
.contains(&"/path/to/file.png".to_string())
);
}
#[test]
fn outgoing_response_text_empty_attachments() {
let response = OutgoingResponse::text("Hello");
assert_eq!(response.content, "Hello");
assert!(response.attachments.is_empty());
}
// ── metadata assertion tests ────────────────────────────────────
#[test]
@@ -2450,4 +2654,116 @@ mod tests {
assert_eq!(ch.config.http_url, "http://127.0.0.1:8686");
Ok(())
}
// ── attachment path validation ───────────────────────────────────
#[test]
fn validate_attachment_paths_rejects_double_dot() {
let paths = vec!["../etc/passwd".to_string()];
let result = SignalChannel::validate_attachment_paths(&paths);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("forbidden") || err.contains("sandbox"));
}
#[test]
fn validate_attachment_paths_accepts_normal_paths() {
use std::fs;
// Create test files in sandbox
let base_dir = dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".ironclaw");
// Create sandbox directory if it doesn't exist (needed for CI)
let _ = fs::create_dir_all(&base_dir);
let temp_dir = tempfile::tempdir_in(&base_dir).unwrap();
let file1 = temp_dir.path().join("file.txt");
let file2 = temp_dir.path().join("report.pdf");
fs::write(&file1, "test").unwrap();
fs::write(&file2, "test").unwrap();
let paths = vec![
file1.to_string_lossy().to_string(),
file2.to_string_lossy().to_string(),
];
let result = SignalChannel::validate_attachment_paths(&paths);
assert!(result.is_ok());
}
#[test]
fn validate_attachment_paths_rejects_nested_traversal() {
let paths = vec!["foo/../bar/../../secret.txt".to_string()];
let result = SignalChannel::validate_attachment_paths(&paths);
assert!(result.is_err());
}
#[test]
fn validate_attachment_paths_empty_ok() {
let paths: Vec<String> = vec![];
let result = SignalChannel::validate_attachment_paths(&paths);
assert!(result.is_ok());
}
#[test]
fn validate_attachment_paths_rejects_path_outside_sandbox() {
let paths = vec!["/tmp/evil.txt".to_string()];
let result = SignalChannel::validate_attachment_paths(&paths);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("sandbox"));
}
#[test]
fn validate_attachment_paths_rejects_url_encoded_traversal() {
let paths = vec!["%2e%2e%2fetc/passwd".to_string()];
let result = SignalChannel::validate_attachment_paths(&paths);
assert!(result.is_err());
}
#[test]
fn validate_attachment_paths_rejects_null_byte() {
let paths = vec!["file\0.txt".to_string()];
let result = SignalChannel::validate_attachment_paths(&paths);
assert!(result.is_err());
}
// ── conversation context ───────────────────────────────────────────
#[test]
fn conversation_context_extracts_sender() {
let ch = SignalChannel::new(make_config()).unwrap();
let metadata = serde_json::json!({
"signal_sender": "+1234567890",
"signal_sender_uuid": "uuid-123",
"signal_target": "+0987654321"
});
let ctx = ch.conversation_context(&metadata);
assert_eq!(ctx.get("sender"), Some(&"+1234567890".to_string()));
assert_eq!(ctx.get("sender_uuid"), Some(&"uuid-123".to_string()));
assert!(ctx.get("group").is_none());
}
#[test]
fn conversation_context_extracts_group() {
let ch = SignalChannel::new(make_config()).unwrap();
let metadata = serde_json::json!({
"signal_sender": "+1234567890",
"signal_target": "group:mygroup"
});
let ctx = ch.conversation_context(&metadata);
assert_eq!(ctx.get("sender"), Some(&"+1234567890".to_string()));
assert_eq!(ctx.get("group"), Some(&"group:mygroup".to_string()));
}
#[test]
fn conversation_context_empty_for_unknown_channel() {
let ch = SignalChannel::new(make_config()).unwrap();
let metadata = serde_json::json!({
"unknown_key": "value"
});
let ctx = ch.conversation_context(&metadata);
assert!(ctx.is_empty());
}
}