diff --git a/src/agent/thread_ops.rs b/src/agent/thread_ops.rs index 38cf8d0f..91eb0e55 100644 --- a/src/agent/thread_ops.rs +++ b/src/agent/thread_ops.rs @@ -176,6 +176,18 @@ impl Agent { return Ok(SubmissionResult::error("Input rejected by safety policy.")); } + // Scan inbound messages for secrets (API keys, tokens). + // Catching them here prevents the LLM from echoing them back, which + // would trigger the outbound leak detector and create error loops. + if let Some(warning) = self.safety().scan_inbound_for_secrets(content) { + tracing::warn!( + user = %message.user_id, + channel = %message.channel, + "Inbound message blocked: contains leaked secret" + ); + return Ok(SubmissionResult::error(warning)); + } + // Handle explicit commands (starting with /) directly // Everything else goes through the normal agentic loop with tools let temp_message = IncomingMessage { diff --git a/src/safety/mod.rs b/src/safety/mod.rs index 87831edc..cb4d5d55 100644 --- a/src/safety/mod.rs +++ b/src/safety/mod.rs @@ -126,6 +126,22 @@ impl SafetyLayer { self.validator.validate(input) } + /// Scan user input for leaked secrets (API keys, tokens, etc.). + /// + /// Returns `Some(warning)` if the input contains what looks like a secret, + /// so the caller can reject the message early instead of sending it to the + /// LLM (which might echo it back and trigger an outbound block loop). + pub fn scan_inbound_for_secrets(&self, input: &str) -> Option { + let warning = "Your message appears to contain a secret (API key, token, or credential). \ + For security, it was not sent to the AI. Please remove the secret and try again. \ + To store credentials, use the setup form or `ironclaw config set `."; + match self.leak_detector.scan_and_clean(input) { + Ok(cleaned) if cleaned != input => Some(warning.to_string()), + Err(_) => Some(warning.to_string()), + _ => None, // Clean input + } + } + /// Check if content violates any policy rules. pub fn check_policy(&self, content: &str) -> Vec<&PolicyRule> { self.policy.check(content)