Skip to main content

lean_ctx/core/agents/
host_mirror.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Mirror of a host's own agent-to-agent message (Claude Code `SendMessage`)
3//! onto the local bus scratchpad, so `ctx_agent read` and the dashboard see
4//! direct traffic the host delivers outside lean-ctx.
5//!
6//! - Local only: the entry is stored like any `ctx_agent post` — admitted
7//!   (redaction, withholding) and bound before persistence — with privacy
8//!   `Private`, so the Hive relay never forwards it, whatever its recipient.
9//!   These are conversations between the machine's own agents.
10//! - Secrets are redacted on the FULL text before the size cap, so a cut can
11//!   never leave an unrecognisable secret prefix behind.
12//! - One entry per tool call: the host's `tool_use_id` is the idempotency
13//!   key, so a hook that fires twice (global + project settings, a retry)
14//!   writes once.
15
16use super::AgentRegistry;
17use crate::core::a2a::message::{MessagePriority, PrivacyLevel};
18
19/// Bus category of a mirrored host message.
20pub const HOST_MIRROR_CATEGORY: &str = "direct";
21/// Largest mirrored body in bytes, before the truncation marker.
22pub const HOST_MIRROR_MAX_BYTES: usize = 8 * 1024;
23const KEY_PREFIX: &str = "host-mirror:";
24
25/// One host message to mirror.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct HostMessage {
28    pub project_root: String,
29    pub from_agent: String,
30    pub to_agent: String,
31    pub body: String,
32    pub tool_use_id: String,
33}
34
35/// `text` cut to at most `max` bytes on a char boundary, with a marker
36/// naming how many bytes were dropped. Shorter text is returned unchanged.
37#[must_use]
38pub fn cap_host_message(text: &str, max: usize) -> String {
39    if text.len() <= max {
40        return text.to_string();
41    }
42    let mut cut = max;
43    while !text.is_char_boundary(cut) {
44        cut -= 1;
45    }
46    format!(
47        "{}\n[lean-ctx: truncated, {} bytes omitted]",
48        &text[..cut],
49        text.len() - cut
50    )
51}
52
53/// Store `message` on the local bus; returns the entry id (the first id again
54/// for a repeated `tool_use_id`). Errors are content-free and meant to be
55/// dropped by a fail-open caller.
56pub fn mirror_host_message(message: &HostMessage) -> Result<String, String> {
57    let redacted = crate::core::redaction::redact_text(&message.body);
58    let body = cap_host_message(&redacted, HOST_MIRROR_MAX_BYTES);
59    let key = format!("{KEY_PREFIX}{}", message.tool_use_id);
60    AgentRegistry::mutate_locked(|registry| {
61        registry.post_message_keyed(
62            Some(&message.project_root),
63            &message.from_agent,
64            Some(&message.to_agent),
65            HOST_MIRROR_CATEGORY,
66            &body,
67            PrivacyLevel::Private,
68            MessagePriority::Normal,
69            None,
70            Some(&key),
71        )
72    })
73    .and_then(|(_, posted)| posted)
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    fn message(tool_use_id: &str, body: &str, root: &str) -> HostMessage {
81        HostMessage {
82            project_root: root.to_string(),
83            from_agent: "claude:s1".to_string(),
84            to_agent: "worker".to_string(),
85            body: body.to_string(),
86            tool_use_id: tool_use_id.to_string(),
87        }
88    }
89
90    fn mirrored() -> Vec<super::super::ScratchpadEntry> {
91        AgentRegistry::load_or_create()
92            .scratchpad
93            .into_iter()
94            .filter(|e| e.category == HOST_MIRROR_CATEGORY)
95            .collect()
96    }
97
98    #[test]
99    fn same_tool_use_id_is_stored_once() {
100        let _data = crate::core::data_dir::isolated_data_dir();
101        let _policy = crate::core::policy::runtime::TestPolicyOverride::set(None);
102        let dir = tempfile::tempdir().unwrap();
103        let root = dir.path().to_string_lossy().into_owned();
104        let first = mirror_host_message(&message("toolu_1", "build green", &root)).unwrap();
105        let again = mirror_host_message(&message("toolu_1", "build green", &root)).unwrap();
106        assert_eq!(first, again, "a repeated hook returns the first entry");
107        mirror_host_message(&message("toolu_2", "build green", &root)).unwrap();
108        let entries = mirrored();
109        assert_eq!(entries.len(), 2);
110        assert_eq!(entries[0].to_agent.as_deref(), Some("worker"));
111        assert_eq!(entries[0].from_agent, "claude:s1");
112        assert_eq!(
113            entries[0].privacy,
114            PrivacyLevel::Private,
115            "mirrors never reach Hive"
116        );
117        assert_eq!(entries[0].message, "build green");
118    }
119
120    #[test]
121    fn oversized_body_is_capped_with_a_marker() {
122        let _data = crate::core::data_dir::isolated_data_dir();
123        let _policy = crate::core::policy::runtime::TestPolicyOverride::set(None);
124        let dir = tempfile::tempdir().unwrap();
125        let root = dir.path().to_string_lossy().into_owned();
126        let body = "é".repeat(HOST_MIRROR_MAX_BYTES);
127        mirror_host_message(&message("toolu_big", &body, &root)).unwrap();
128        let stored = &mirrored()[0].message;
129        assert!(
130            stored.len() < HOST_MIRROR_MAX_BYTES + 64,
131            "{}",
132            stored.len()
133        );
134        assert!(stored.ends_with(&format!(
135            "[lean-ctx: truncated, {HOST_MIRROR_MAX_BYTES} bytes omitted]"
136        )));
137        assert_eq!(cap_host_message("short", 10), "short");
138        assert_eq!(
139            cap_host_message("aé", 2),
140            "a\n[lean-ctx: truncated, 2 bytes omitted]"
141        );
142    }
143
144    #[test]
145    fn a_credential_never_reaches_the_store() {
146        let _data = crate::core::data_dir::isolated_data_dir();
147        let _policy = crate::core::policy::runtime::TestPolicyOverride::set(None);
148        let dir = tempfile::tempdir().unwrap();
149        let root = dir.path().to_string_lossy().into_owned();
150        let key = concat!("AK", "IAIOSFODNN7EXAMPLE");
151        // The secret straddles the cap: redaction runs on the full text first.
152        let body = format!("{}aws_key = {key}", "x".repeat(HOST_MIRROR_MAX_BYTES - 16));
153        mirror_host_message(&message("toolu_s", &body, &root)).unwrap();
154        let stored = &mirrored()[0].message;
155        assert!(!stored.contains(key));
156        assert!(
157            !stored.contains(&key[..8]),
158            "no secret prefix left by the cut"
159        );
160        let raw = std::fs::read_to_string(
161            crate::core::data_dir::lean_ctx_data_dir()
162                .unwrap()
163                .join("agents/registry.json"),
164        )
165        .unwrap();
166        assert!(!raw.contains(key));
167    }
168}