diff --git a/src/agent/branch.rs b/src/agent/branch.rs index f9554342a..001eab7ea 100644 --- a/src/agent/branch.rs +++ b/src/agent/branch.rs @@ -154,11 +154,14 @@ impl Branch { // Scrub tool secret values from the conclusion before sending to the // channel. Branches can spawn workers whose output may contain secrets. + // Layer 1: exact-match redaction of known secrets from the store. + // Layer 2: regex-based redaction of unknown secret patterns. let conclusion = if let Some(store) = self.deps.runtime_config.secrets.load().as_ref() { crate::secrets::scrub::scrub_with_store(&conclusion, store) } else { conclusion }; + let conclusion = crate::secrets::scrub::scrub_leaks(&conclusion); // Send conclusion back to the channel let _ = self.deps.event_tx.send(ProcessEvent::BranchResult { diff --git a/src/agent/channel_dispatch.rs b/src/agent/channel_dispatch.rs index b86fd7feb..d406271dd 100644 --- a/src/agent/channel_dispatch.rs +++ b/src/agent/channel_dispatch.rs @@ -249,6 +249,7 @@ async fn spawn_branch( let event_tx = state.deps.event_tx.clone(); let agent_id = state.deps.agent_id.clone(); let channel_id = state.channel_id.clone(); + let secrets_snapshot = state.deps.runtime_config.secrets.load().clone(); let branch_span = tracing::info_span!( "branch.run", @@ -260,11 +261,22 @@ async fn spawn_branch( async move { if let Err(error) = branch.run(&prompt).await { tracing::error!(branch_id = %branch_id, %error, "branch failed"); + // Scrub the failure message in case the error contains secrets + // (e.g. from failed tool calls echoing back prompt content). + // Layer 1: exact-match redaction of known secrets from the store. + // Layer 2: regex-based redaction of unknown secret patterns. + let raw = format!("Branch failed: {error}"); + let conclusion = if let Some(store) = secrets_snapshot.as_ref() { + crate::secrets::scrub::scrub_with_store(&raw, store) + } else { + raw + }; + let conclusion = crate::secrets::scrub::scrub_leaks(&conclusion); let _ = event_tx.send(crate::ProcessEvent::BranchResult { agent_id, branch_id, channel_id, - conclusion: format!("Branch failed: {error}"), + conclusion, }); } } @@ -591,11 +603,14 @@ where Ok(Ok(text)) => { // Scrub tool secret values from the result before it reaches // the channel. The channel never sees raw secret values. + // Layer 1: exact-match redaction of known secrets from the store. + // Layer 2: regex-based redaction of unknown secret patterns. let scrubbed = if let Some(store) = &secrets_store { crate::secrets::scrub::scrub_with_store(&text, store) } else { text }; + let scrubbed = crate::secrets::scrub::scrub_leaks(&scrubbed); Ok(scrubbed) } Ok(Err(error)) => { @@ -608,6 +623,7 @@ where } else { message }; + let scrubbed = crate::secrets::scrub::scrub_leaks(&scrubbed); Err(WorkerCompletionError::Failed { message: scrubbed }) } } diff --git a/src/agent/cortex.rs b/src/agent/cortex.rs index d2ff4811b..4fca18133 100644 --- a/src/agent/cortex.rs +++ b/src/agent/cortex.rs @@ -1260,9 +1260,22 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho let links = deps.links.clone(); let agent_names = deps.agent_names.clone(); let sqlite_pool = deps.sqlite_pool.clone(); + let secrets_snapshot = deps.runtime_config.secrets.load().clone(); tokio::spawn(async move { + // Helper closure: scrub both known secrets (Layer 1) and unknown leak + // patterns (Layer 2) from text before it reaches channels or events. + let scrub = |text: String| -> String { + let scrubbed = if let Some(store) = secrets_snapshot.as_ref() { + crate::secrets::scrub::scrub_with_store(&text, store) + } else { + text + }; + crate::secrets::scrub::scrub_leaks(&scrubbed) + }; + match worker.run().await { - Ok(result_text) => { + Ok(raw_result) => { + let result_text = scrub(raw_result); let db_updated = task_store .update( &agent_id, @@ -1325,7 +1338,7 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho } Err(error) => { let (error_message, notify, success) = map_worker_completion_result(Err( - WorkerCompletionError::failed(error.to_string()), + WorkerCompletionError::failed(scrub(error.to_string())), )); run_logger.log_worker_completed(worker_id, &error_message, false); diff --git a/src/hooks/spacebot.rs b/src/hooks/spacebot.rs index 934797d33..6ea610021 100644 --- a/src/hooks/spacebot.rs +++ b/src/hooks/spacebot.rs @@ -250,18 +250,54 @@ impl SpacebotHook { } /// Apply shared safety checks for tool output before any downstream handling. + /// + /// For channels, a detected secret terminates the agent immediately to prevent + /// exfiltration via the `reply` tool. For workers and branches, secrets are + /// logged but execution continues — these processes cannot communicate with + /// users directly, and their egress paths (worker results, branch conclusions, + /// status updates) apply scrubbing before content reaches the channel. pub(crate) fn guard_tool_result(&self, tool_name: &str, result: &str) -> HookAction { if let Some(leak) = self.scan_for_leaks(result) { - tracing::error!( - process_id = %self.process_id, - tool_name = %tool_name, - leak_prefix = %&leak[..leak.len().min(8)], - "secret leak detected in tool output, terminating agent" - ); - return HookAction::Terminate { - reason: "Tool output contained a secret. Agent terminated to prevent exfiltration." - .into(), - }; + match self.process_type { + ProcessType::Worker | ProcessType::Branch => { + // Workers and branches cannot communicate with users directly. + // Their egress paths (worker results, branch conclusions, + // status updates) scrub secrets before content reaches the + // channel. Log and continue rather than killing the process. + // + // Avoid logging any fragment of the matched secret. Only log + // the encoding kind (plaintext/url/base64/hex) and length. + let kind = if leak.starts_with("url-encoded:") { + "url-encoded" + } else if leak.starts_with("base64-encoded:") { + "base64-encoded" + } else if leak.starts_with("hex-encoded:") { + "hex-encoded" + } else { + "plaintext" + }; + tracing::warn!( + process_id = %self.process_id, + tool_name = %tool_name, + leak_kind = kind, + leak_len = leak.len(), + "secret detected in tool output (non-channel process, continuing)" + ); + } + ProcessType::Channel | ProcessType::Compactor | ProcessType::Cortex => { + tracing::error!( + process_id = %self.process_id, + tool_name = %tool_name, + leak_prefix = %&leak[..leak.len().min(8)], + "secret leak detected in tool output, terminating agent" + ); + return HookAction::Terminate { + reason: + "Tool output contained a secret. Agent terminated to prevent exfiltration." + .into(), + }; + } + } } HookAction::Continue @@ -488,9 +524,10 @@ where return guard_action; } - // Only enforce hard-stop leak blocking on channel egress (`reply`). - // Worker and branch tool outputs are internal and should not terminate - // long-running jobs. + // Belt-and-suspenders check specifically for `reply` tool results on + // channels. `guard_tool_result` already terminates channels on any tool + // leak, but this catches any edge case where the reply content itself + // has a different leak than the raw tool output. if self.process_type == ProcessType::Channel && tool_name == "reply" && let Some(leak) = self.scan_for_leaks(result) @@ -508,8 +545,17 @@ where } // Cap the result stored in the broadcast event to avoid blowing up - // event subscribers with multi-MB tool results. - self.emit_tool_completed_event(tool_name, result); + // event subscribers with multi-MB tool results. For worker/branch + // processes, scrub leak patterns from the event payload so secrets + // don't reach the SSE dashboard. + if matches!(self.process_type, ProcessType::Worker | ProcessType::Branch) { + let scrubbed = crate::secrets::scrub::scrub_leaks(result); + let capped = + crate::tools::truncate_output(&scrubbed, crate::tools::MAX_TOOL_OUTPUT_BYTES); + self.emit_tool_completed_event_from_capped(tool_name, capped); + } else { + self.emit_tool_completed_event(tool_name, result); + } tracing::debug!( process_id = %self.process_id, diff --git a/src/secrets/scrub.rs b/src/secrets/scrub.rs index 0df22e4f5..3ffc354a3 100644 --- a/src/secrets/scrub.rs +++ b/src/secrets/scrub.rs @@ -33,6 +33,14 @@ static LEAK_PATTERNS: LazyLock> = LazyLock::new(|| { ] }); +/// Full PEM block pattern (header + base64 body + footer). Used by `scrub_leaks()` +/// to redact entire private key blocks, not just the header line matched by +/// `LEAK_PATTERNS`. +static PEM_BLOCK: LazyLock = LazyLock::new(|| { + Regex::new(r"(?s)-----BEGIN[A-Z \r\n]*PRIVATE KEY-----.*?-----END[A-Z \r\n]*PRIVATE KEY-----") + .expect("hardcoded regex") +}); + static BASE64_SEGMENT: LazyLock = LazyLock::new(|| Regex::new(r"[A-Za-z0-9+/]{24,}={0,2}").expect("hardcoded regex")); @@ -222,6 +230,30 @@ pub fn scrub_with_store(text: &str, store: &crate::secrets::store::SecretsStore) scrub_secrets(text, &pairs) } +/// Replace all detected leak patterns in `content` with a redaction marker. +/// +/// Unlike `scan_for_leaks()` which returns the first match, this function +/// replaces **all** plaintext matches of known API key patterns with +/// `[LEAKED_SECRET_REDACTED]`. Used on egress paths (worker results, branch +/// conclusions, status updates) to sanitize content that may contain secrets +/// the LLM encountered during tool execution. +/// +/// Does NOT check encoded forms (base64, hex, URL-encoded) — those are +/// unlikely to appear in LLM-generated output text. +pub fn scrub_leaks(content: &str) -> String { + // First, redact full PEM blocks (header + body + footer) so the base64 + // key material is removed, not just the header line. + let mut result = PEM_BLOCK + .replace_all(content, "[LEAKED_SECRET_REDACTED]") + .into_owned(); + for pattern in LEAK_PATTERNS.iter() { + result = pattern + .replace_all(&result, "[LEAKED_SECRET_REDACTED]") + .into_owned(); + } + result +} + #[cfg(test)] mod tests { use super::*; @@ -284,4 +316,68 @@ mod tests { let result = scan_for_leaks("this is normal output with no secrets"); assert!(result.is_none()); } + + #[test] + fn scrub_leaks_redacts_anthropic_key() { + let input = "found key sk-ant-abc123456789012345678 in config"; + let result = scrub_leaks(input); + assert!( + !result.contains("sk-ant-abc123456789012345678"), + "secret should be redacted in: {result}" + ); + assert!( + result.contains("[LEAKED_SECRET_REDACTED]"), + "redaction marker missing in: {result}" + ); + assert!( + result.contains("found key"), + "surrounding text should be preserved in: {result}" + ); + } + + #[test] + fn scrub_leaks_redacts_multiple_secrets() { + let input = + "keys: sk-ant-abc123456789012345678 and ghp_abcdefghijklmnopqrstuvwxyz0123456789"; + let result = scrub_leaks(input); + assert!( + !result.contains("sk-ant-"), + "first secret should be redacted in: {result}" + ); + assert!( + !result.contains("ghp_"), + "second secret should be redacted in: {result}" + ); + } + + #[test] + fn scrub_leaks_passes_through_clean_text() { + let input = "this is normal output with no secrets"; + assert_eq!(scrub_leaks(input), input); + } + + #[test] + fn scrub_leaks_redacts_full_pem_block() { + let input = "config:\n-----BEGIN RSA PRIVATE KEY-----\n\ + MIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8PbnGy0AHB7MhgHcTz6sE2I2yPB\n\ + aFDrBz9vFqU4zK3L3hUfVnEy\n\ + -----END RSA PRIVATE KEY-----\nmore text"; + let result = scrub_leaks(input); + assert!( + !result.contains("MIIEpAIBAAK"), + "PEM body should be redacted in: {result}" + ); + assert!( + !result.contains("BEGIN RSA PRIVATE KEY"), + "PEM header should be redacted in: {result}" + ); + assert!( + result.contains("[LEAKED_SECRET_REDACTED]"), + "redaction marker missing in: {result}" + ); + assert!( + result.contains("more text"), + "surrounding text should be preserved in: {result}" + ); + } } diff --git a/src/tools/set_status.rs b/src/tools/set_status.rs index 2b50ed9c6..23633de2e 100644 --- a/src/tools/set_status.rs +++ b/src/tools/set_status.rs @@ -101,7 +101,10 @@ impl Tool for SetStatusTool { }; // Scrub tool secret values before the status reaches the channel. + // Layer 1: exact-match redaction of known secrets from the store. + // Layer 2: regex-based redaction of unknown secret patterns. let status = crate::secrets::scrub::scrub_secrets(&status, &self.tool_secret_pairs); + let status = crate::secrets::scrub::scrub_leaks(&status); let event = ProcessEvent::WorkerStatus { agent_id: self.agent_id.clone(),