Files
optimclaw/src/agent/heartbeat.rs
T
ibhagwanandGitHub bf35b59222 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.
2026-02-26 18:06:21 +04:00

498 lines
16 KiB
Rust

//! Proactive heartbeat system for periodic execution.
//!
//! The heartbeat runner executes periodically (default: every 30 minutes) and:
//! 1. Reads the HEARTBEAT.md checklist
//! 2. Runs an agent turn to process the checklist
//! 3. Reports any findings to the configured channel
//!
//! If nothing needs attention, the agent replies "HEARTBEAT_OK" and no
//! message is sent to the user.
//!
//! # Usage
//!
//! Create a HEARTBEAT.md in the workspace with a checklist of things to monitor:
//!
//! ```markdown
//! # Heartbeat Checklist
//!
//! - [ ] Check for unread emails
//! - [ ] Review calendar for upcoming events
//! - [ ] Check project build status
//! ```
//!
//! The agent will process this checklist on each heartbeat and only notify
//! if action is needed.
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use crate::channels::OutgoingResponse;
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider, Reasoning};
use crate::safety::SafetyLayer;
use crate::workspace::Workspace;
use crate::workspace::hygiene::HygieneConfig;
/// Configuration for the heartbeat runner.
#[derive(Debug, Clone)]
pub struct HeartbeatConfig {
/// Interval between heartbeat checks.
pub interval: Duration,
/// Whether heartbeat is enabled.
pub enabled: bool,
/// Maximum consecutive failures before disabling.
pub max_failures: u32,
/// User ID to notify on heartbeat findings.
pub notify_user_id: Option<String>,
/// Channel to notify on heartbeat findings.
pub notify_channel: Option<String>,
}
impl Default for HeartbeatConfig {
fn default() -> Self {
Self {
interval: Duration::from_secs(30 * 60), // 30 minutes
enabled: true,
max_failures: 3,
notify_user_id: None,
notify_channel: None,
}
}
}
impl HeartbeatConfig {
/// Create a config with a specific interval.
pub fn with_interval(mut self, interval: Duration) -> Self {
self.interval = interval;
self
}
/// Disable heartbeat.
pub fn disabled(mut self) -> Self {
self.enabled = false;
self
}
/// Set the notification target.
pub fn with_notify(mut self, user_id: impl Into<String>, channel: impl Into<String>) -> Self {
self.notify_user_id = Some(user_id.into());
self.notify_channel = Some(channel.into());
self
}
}
/// Result of a heartbeat check.
#[derive(Debug)]
pub enum HeartbeatResult {
/// Nothing needs attention.
Ok,
/// Something needs attention, with the message to send.
NeedsAttention(String),
/// Heartbeat was skipped (no checklist or disabled).
Skipped,
/// Heartbeat failed.
Failed(String),
}
/// Heartbeat runner for proactive periodic execution.
pub struct HeartbeatRunner {
config: HeartbeatConfig,
hygiene_config: HygieneConfig,
workspace: Arc<Workspace>,
llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
consecutive_failures: u32,
}
impl HeartbeatRunner {
/// Create a new heartbeat runner.
pub fn new(
config: HeartbeatConfig,
hygiene_config: HygieneConfig,
workspace: Arc<Workspace>,
llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
) -> Self {
Self {
config,
hygiene_config,
workspace,
llm,
safety,
response_tx: None,
consecutive_failures: 0,
}
}
/// Set the response channel for notifications.
pub fn with_response_channel(mut self, tx: mpsc::Sender<OutgoingResponse>) -> Self {
self.response_tx = Some(tx);
self
}
/// Run the heartbeat loop.
///
/// This runs forever, checking periodically based on the configured interval.
pub async fn run(&mut self) {
if !self.config.enabled {
tracing::info!("Heartbeat is disabled, not starting loop");
return;
}
tracing::info!(
"Starting heartbeat loop with interval {:?}",
self.config.interval
);
let mut interval = tokio::time::interval(self.config.interval);
// Don't run immediately on startup
interval.tick().await;
loop {
interval.tick().await;
// Run memory hygiene in the background so it never delays the
// heartbeat checklist. Failures are logged inside run_if_due.
let hygiene_workspace = Arc::clone(&self.workspace);
let hygiene_config = self.hygiene_config.clone();
tokio::spawn(async move {
let report =
crate::workspace::hygiene::run_if_due(&hygiene_workspace, &hygiene_config)
.await;
if report.had_work() {
tracing::info!(
daily_logs_deleted = report.daily_logs_deleted,
"heartbeat: memory hygiene deleted stale documents"
);
}
});
match self.check_heartbeat().await {
HeartbeatResult::Ok => {
tracing::debug!("Heartbeat OK");
self.consecutive_failures = 0;
}
HeartbeatResult::NeedsAttention(message) => {
tracing::info!("Heartbeat needs attention: {}", message);
self.consecutive_failures = 0;
self.send_notification(&message).await;
}
HeartbeatResult::Skipped => {
tracing::debug!("Heartbeat skipped");
}
HeartbeatResult::Failed(error) => {
tracing::error!("Heartbeat failed: {}", error);
self.consecutive_failures += 1;
if self.consecutive_failures >= self.config.max_failures {
tracing::error!(
"Heartbeat disabled after {} consecutive failures",
self.consecutive_failures
);
break;
}
}
}
}
}
/// Run a single heartbeat check.
pub async fn check_heartbeat(&self) -> HeartbeatResult {
// Get the heartbeat checklist
let checklist = match self.workspace.heartbeat_checklist().await {
Ok(Some(content)) if !is_effectively_empty(&content) => content,
Ok(_) => return HeartbeatResult::Skipped,
Err(e) => return HeartbeatResult::Failed(format!("Failed to read checklist: {}", e)),
};
// Build the heartbeat prompt
let prompt = format!(
"Read the HEARTBEAT.md checklist below and follow it strictly. \
Do not infer or repeat old tasks. Check each item and report findings.\n\
\n\
If nothing needs attention, reply EXACTLY with: HEARTBEAT_OK\n\
\n\
If something needs attention, provide a concise summary of what needs action.\n\
\n\
## HEARTBEAT.md\n\
\n\
{}",
checklist
);
// Get the system prompt for context
let system_prompt = match self.workspace.system_prompt().await {
Ok(p) => p,
Err(e) => {
tracing::warn!("Failed to get system prompt for heartbeat: {}", e);
String::new()
}
};
// Run the agent turn
let messages = if system_prompt.is_empty() {
vec![ChatMessage::user(&prompt)]
} else {
vec![
ChatMessage::system(&system_prompt),
ChatMessage::user(&prompt),
]
};
// Use the model's context_length to set max_tokens. The API returns
// the total context window; we cap output at half of that (the rest is
// the prompt) with a floor of 4096.
let max_tokens = match self.llm.model_metadata().await {
Ok(meta) => {
let from_api = meta.context_length.map(|ctx| ctx / 2).unwrap_or(4096);
from_api.max(4096)
}
Err(e) => {
tracing::warn!(
"Could not fetch model metadata, using default max_tokens: {}",
e
);
4096
}
};
let request = CompletionRequest::new(messages)
.with_max_tokens(max_tokens)
.with_temperature(0.3);
let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone());
let (content, _usage) = match reasoning.complete(request).await {
Ok(r) => r,
Err(e) => return HeartbeatResult::Failed(format!("LLM call failed: {}", e)),
};
let content = content.trim();
// Guard against empty content. Reasoning models (e.g. GLM-4.7) may
// burn all output tokens on chain-of-thought and return content: null.
if content.is_empty() {
return HeartbeatResult::Failed("LLM returned empty content.".to_string());
}
// Check if nothing needs attention
if content == "HEARTBEAT_OK" || content.contains("HEARTBEAT_OK") {
return HeartbeatResult::Ok;
}
HeartbeatResult::NeedsAttention(content.to_string())
}
/// Send a notification about heartbeat findings.
async fn send_notification(&self, message: &str) {
let Some(ref tx) = self.response_tx else {
tracing::debug!("No response channel configured for heartbeat notifications");
return;
};
let response = OutgoingResponse {
content: format!("🔔 *Heartbeat Alert*\n\n{}", message),
thread_id: None,
attachments: Vec::new(),
metadata: serde_json::json!({
"source": "heartbeat",
}),
};
if let Err(e) = tx.send(response).await {
tracing::error!("Failed to send heartbeat notification: {}", e);
}
}
}
/// Check if heartbeat content is effectively empty.
///
/// Returns true if the content contains only:
/// - Whitespace
/// - Markdown headers (lines starting with #)
/// - HTML comments (`<!-- ... -->`)
/// - Empty list items (`- [ ]`, `- [x]`, `-`, `*`)
///
/// This skips the LLM call when the user hasn't added real tasks yet,
/// saving API costs.
fn is_effectively_empty(content: &str) -> bool {
let without_comments = strip_html_comments(content);
without_comments.lines().all(|line| {
let trimmed = line.trim();
trimmed.is_empty()
|| trimmed.starts_with('#')
|| trimmed == "- [ ]"
|| trimmed == "- [x]"
|| trimmed == "-"
|| trimmed == "*"
})
}
/// Remove HTML comments from content.
fn strip_html_comments(content: &str) -> String {
let mut result = String::with_capacity(content.len());
let mut rest = content;
while let Some(start) = rest.find("<!--") {
result.push_str(&rest[..start]);
match rest[start..].find("-->") {
Some(end) => rest = &rest[start + end + 3..],
None => return result, // unclosed comment, treat rest as comment
}
}
result.push_str(rest);
result
}
/// Spawn the heartbeat runner as a background task.
///
/// Returns a handle that can be used to stop the runner.
pub fn spawn_heartbeat(
config: HeartbeatConfig,
hygiene_config: HygieneConfig,
workspace: Arc<Workspace>,
llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
) -> tokio::task::JoinHandle<()> {
let mut runner = HeartbeatRunner::new(config, hygiene_config, workspace, llm, safety);
if let Some(tx) = response_tx {
runner = runner.with_response_channel(tx);
}
tokio::spawn(async move {
runner.run().await;
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_heartbeat_config_defaults() {
let config = HeartbeatConfig::default();
assert!(config.enabled);
assert_eq!(config.interval, Duration::from_secs(30 * 60));
assert_eq!(config.max_failures, 3);
}
#[test]
fn test_heartbeat_config_builders() {
let config = HeartbeatConfig::default()
.with_interval(Duration::from_secs(60))
.with_notify("user1", "telegram");
assert_eq!(config.interval, Duration::from_secs(60));
assert_eq!(config.notify_user_id, Some("user1".to_string()));
assert_eq!(config.notify_channel, Some("telegram".to_string()));
let disabled = HeartbeatConfig::default().disabled();
assert!(!disabled.enabled);
}
// ==================== strip_html_comments ====================
#[test]
fn test_strip_html_comments_no_comments() {
assert_eq!(strip_html_comments("hello world"), "hello world");
}
#[test]
fn test_strip_html_comments_single() {
assert_eq!(
strip_html_comments("before<!-- gone -->after"),
"beforeafter"
);
}
#[test]
fn test_strip_html_comments_multiple() {
let input = "a<!-- 1 -->b<!-- 2 -->c";
assert_eq!(strip_html_comments(input), "abc");
}
#[test]
fn test_strip_html_comments_multiline() {
let input = "# Title\n<!-- multi\nline\ncomment -->\nreal content";
assert_eq!(strip_html_comments(input), "# Title\n\nreal content");
}
#[test]
fn test_strip_html_comments_unclosed() {
let input = "before<!-- never closed";
assert_eq!(strip_html_comments(input), "before");
}
// ==================== is_effectively_empty ====================
#[test]
fn test_effectively_empty_empty_string() {
assert!(is_effectively_empty(""));
}
#[test]
fn test_effectively_empty_whitespace() {
assert!(is_effectively_empty(" \n\n \n "));
}
#[test]
fn test_effectively_empty_headers_only() {
assert!(is_effectively_empty("# Title\n## Subtitle\n### Section"));
}
#[test]
fn test_effectively_empty_html_comments_only() {
assert!(is_effectively_empty("<!-- this is a comment -->"));
}
#[test]
fn test_effectively_empty_empty_checkboxes() {
assert!(is_effectively_empty("# Checklist\n- [ ]\n- [x]"));
}
#[test]
fn test_effectively_empty_bare_list_markers() {
assert!(is_effectively_empty("-\n*\n-"));
}
#[test]
fn test_effectively_empty_seeded_template() {
let template = "\
# Heartbeat Checklist
<!-- Keep this file empty to skip heartbeat API calls.
Add tasks below when you want the agent to check something periodically.
Example:
- [ ] Check for unread emails needing a reply
- [ ] Review today's calendar for upcoming meetings
- [ ] Check CI build status for main branch
-->";
assert!(is_effectively_empty(template));
}
#[test]
fn test_effectively_empty_real_checklist() {
let content = "\
# Heartbeat Checklist
- [ ] Check for unread emails needing a reply
- [ ] Review today's calendar for upcoming meetings";
assert!(!is_effectively_empty(content));
}
#[test]
fn test_effectively_empty_mixed_real_and_headers() {
let content = "# Title\n\nDo something important";
assert!(!is_effectively_empty(content));
}
#[test]
fn test_effectively_empty_comment_plus_real_content() {
let content = "<!-- comment -->\nActual task here";
assert!(!is_effectively_empty(content));
}
}