Skip to main content

lean_ctx/core/addons/
runtime.rs

1//! Runtime safeguards for addon tool output (#866).
2//!
3//! An addon's tool result is **untrusted content** that flows straight into the
4//! model context — both an exfiltration surface (the addon could echo back a
5//! secret it read) and a prompt-injection surface. Before the gateway hands a
6//! downstream result to the model ([`crate::core::gateway::proxy`]), it runs the
7//! output through the same redaction the shell layer uses (single source of
8//! truth: [`crate::core::redaction`] + [`crate::core::secret_detection`]) and
9//! records an audit line tagging the bytes as untrusted, attributed to the
10//! originating server.
11
12/// Redact secrets from a downstream addon's tool output and emit an audit trace
13/// marking it untrusted. Returns the scrubbed text the model will see.
14#[must_use]
15pub fn scrub_output(server: &str, text: &str) -> String {
16    let masked = crate::core::redaction::redact_text(text);
17    let (redacted, matches) = crate::core::secret_detection::scan_and_redact_from_config(&masked);
18
19    if !matches.is_empty() {
20        let mut names: Vec<&str> = matches.iter().map(|m| m.pattern_name).collect();
21        names.sort_unstable();
22        names.dedup();
23        tracing::warn!(
24            "[ADDON OUTPUT REDACTION] {} secret(s) redacted from untrusted server `{server}` output: {}",
25            matches.len(),
26            names.join(", ")
27        );
28    }
29    tracing::debug!(
30        "[ADDON UNTRUSTED OUTPUT] server=`{server}` bytes={} — entered model context as untrusted content",
31        redacted.len()
32    );
33    redacted
34}
35
36#[cfg(test)]
37mod tests {
38    use super::*;
39
40    #[test]
41    fn passes_clean_output_through() {
42        let out = scrub_output("demo", "hello world, nothing secret here");
43        assert_eq!(out, "hello world, nothing secret here");
44    }
45
46    #[test]
47    fn redacts_a_secret_in_addon_output() {
48        // A GitHub token the addon tried to echo back must not reach the model.
49        let leaked = "token=ghp_0123456789abcdefghijklmnopqrstuvwxyzAB";
50        let out = scrub_output("evil", leaked);
51        assert!(!out.contains("ghp_0123456789abcdefghijklmnopqrstuvwxyzAB"));
52        assert!(out.contains("REDACTED"));
53    }
54}