diff --git a/src/agent/branch.rs b/src/agent/branch.rs index 001eab7ea..4a3e97e50 100644 --- a/src/agent/branch.rs +++ b/src/agent/branch.rs @@ -50,7 +50,9 @@ impl Branch { ProcessType::Branch, Some(channel_id.clone()), deps.event_tx.clone(), - ); + ) + .with_secret_scan_mode(deps.secret_scan_mode()) + .with_secrets_snapshot(deps.runtime_config.secrets.load().as_ref().clone()); Self { id, @@ -161,7 +163,7 @@ impl Branch { } else { conclusion }; - let conclusion = crate::secrets::scrub::scrub_leaks(&conclusion); + let conclusion = self.deps.secret_scan_mode().maybe_scrub_leaks(conclusion); // Send conclusion back to the channel let _ = self.deps.event_tx.send(ProcessEvent::BranchResult { diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 1790bf949..fef551fab 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -379,7 +379,8 @@ impl Channel { ProcessType::Channel, Some(id.clone()), deps.event_tx.clone(), - ); + ) + .with_secret_scan_mode(deps.secret_scan_mode()); let status_block = Arc::new(RwLock::new(StatusBlock::new())); let history = Arc::new(RwLock::new(Vec::new())); let active_branches = Arc::new(RwLock::new(HashMap::new())); @@ -476,6 +477,16 @@ impl Channel { .unwrap_or(self.deps.agent_id.as_ref()) } + /// Check if strict-mode leak detection finds a secret in the given text. + /// Returns `Some(leak)` only when `SecretScanMode::Strict` and a pattern matches. + fn strict_mode_leak(&self, text: &str) -> Option { + if self.deps.secret_scan_mode() == crate::secrets::scrub::SecretScanMode::Strict { + crate::secrets::scrub::scan_for_leaks(text) + } else { + None + } + } + fn current_adapter(&self) -> Option<&str> { self.source_adapter .as_deref() @@ -1933,7 +1944,7 @@ impl Channel { channel_id = %self.id, "blocked retrigger fallback output containing structured or tool syntax" ); - } else if let Some(leak) = crate::secrets::scrub::scan_for_leaks(text) { + } else if let Some(leak) = self.strict_mode_leak(text) { tracing::warn!( channel_id = %self.id, leak_prefix = %&leak[..leak.len().min(8)], @@ -1998,7 +2009,7 @@ impl Channel { channel_id = %self.id, "blocked retrigger output containing structured or tool syntax" ); - } else if let Some(leak) = crate::secrets::scrub::scan_for_leaks(text) { + } else if let Some(leak) = self.strict_mode_leak(text) { tracing::warn!( channel_id = %self.id, leak_prefix = %&leak[..leak.len().min(8)], @@ -2056,7 +2067,7 @@ impl Channel { channel_id = %self.id, "blocked fallback output containing structured or tool syntax" ); - } else if let Some(leak) = crate::secrets::scrub::scan_for_leaks(text) { + } else if let Some(leak) = self.strict_mode_leak(text) { tracing::warn!( channel_id = %self.id, leak_prefix = %&leak[..leak.len().min(8)], diff --git a/src/agent/channel_dispatch.rs b/src/agent/channel_dispatch.rs index 81053bc3d..fb8d12dcf 100644 --- a/src/agent/channel_dispatch.rs +++ b/src/agent/channel_dispatch.rs @@ -251,6 +251,7 @@ async fn spawn_branch( 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 scan_mode = state.deps.secret_scan_mode(); let branch_span = tracing::info_span!( "branch.run", @@ -267,12 +268,9 @@ async fn spawn_branch( // 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 store_ref: Option<&crate::secrets::store::SecretsStore> = + secrets_snapshot.as_ref().as_ref().map(|s| s.as_ref()); + let conclusion = scan_mode.apply_scrubbing_with_store(&raw, store_ref); let _ = event_tx.send(crate::ProcessEvent::BranchResult { agent_id, branch_id, @@ -421,12 +419,13 @@ pub async fn spawn_worker_from_state( task = %task, ); let secrets_store = state.deps.runtime_config.secrets.load().as_ref().clone(); - let handle = spawn_worker_task( + let handle = spawn_worker_task_with_scan_mode( worker_id, state.deps.event_tx.clone(), state.deps.agent_id.clone(), Some(state.channel_id.clone()), secrets_store, + state.deps.secret_scan_mode(), worker.run().instrument(worker_span), ); @@ -487,6 +486,7 @@ pub async fn spawn_opencode_worker_from_state( let server_pool = rc.opencode_server_pool.load().clone(); let oc_secrets_store = state.deps.runtime_config.secrets.load().as_ref().clone(); + let scan_mode = state.deps.secret_scan_mode(); let worker = if interactive { let (worker, input_tx) = crate::opencode::OpenCodeWorker::new_interactive( @@ -503,10 +503,11 @@ pub async fn spawn_opencode_worker_from_state( .write() .await .insert(worker_id, input_tx); - match &oc_secrets_store { + let worker = match &oc_secrets_store { Some(store) => worker.with_secrets_store(store.clone()), None => worker, - } + }; + worker.with_secret_scan_mode(scan_mode) } else { let worker = crate::opencode::OpenCodeWorker::new( Some(state.channel_id.clone()), @@ -516,10 +517,11 @@ pub async fn spawn_opencode_worker_from_state( server_pool, state.deps.event_tx.clone(), ); - match &oc_secrets_store { + let worker = match &oc_secrets_store { Some(store) => worker.with_secrets_store(store.clone()), None => worker, - } + }; + worker.with_secret_scan_mode(scan_mode) }; let worker_id = worker.id; @@ -531,12 +533,13 @@ pub async fn spawn_opencode_worker_from_state( task = %task, worker_type = "opencode", ); - let handle = spawn_worker_task( + let handle = spawn_worker_task_with_scan_mode( worker_id, state.deps.event_tx.clone(), state.deps.agent_id.clone(), Some(state.channel_id.clone()), oc_secrets_store, + scan_mode, async move { let result = worker.run().await.map_err(SpacebotError::from)?; Ok::(result.result_text) @@ -586,6 +589,30 @@ pub(crate) fn spawn_worker_task( secrets_store: Option>, future: F, ) -> tokio::task::JoinHandle<()> +where + F: std::future::Future> + Send + 'static, +{ + spawn_worker_task_with_scan_mode( + worker_id, + event_tx, + agent_id, + channel_id, + secrets_store, + crate::secrets::scrub::SecretScanMode::Strict, + future, + ) +} + +/// Like `spawn_worker_task` but with an explicit secret scan mode. +pub(crate) fn spawn_worker_task_with_scan_mode( + worker_id: WorkerId, + event_tx: broadcast::Sender, + agent_id: crate::AgentId, + channel_id: Option, + secrets_store: Option>, + scan_mode: crate::secrets::scrub::SecretScanMode, + future: F, +) -> tokio::task::JoinHandle<()> where F: std::future::Future> + Send + 'static, { @@ -604,14 +631,8 @@ 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); + let store_ref = secrets_store.as_ref().map(|s| s.as_ref()); + let scrubbed = scan_mode.apply_scrubbing_with_store(&text, store_ref); Ok(scrubbed) } Ok(Err(error)) => { @@ -619,12 +640,9 @@ where match failure { WorkerCompletionError::Cancelled { .. } => Err(failure), WorkerCompletionError::Failed { message } => { - let scrubbed = if let Some(store) = &secrets_store { - crate::secrets::scrub::scrub_with_store(&message, store) - } else { - message - }; - let scrubbed = crate::secrets::scrub::scrub_leaks(&scrubbed); + let store_ref = secrets_store.as_ref().map(|s| s.as_ref()); + let scrubbed = + scan_mode.apply_scrubbing_with_store(&message, store_ref); Err(WorkerCompletionError::Failed { message: scrubbed }) } } diff --git a/src/agent/compactor.rs b/src/agent/compactor.rs index 04567da89..f5c41cd48 100644 --- a/src/agent/compactor.rs +++ b/src/agent/compactor.rs @@ -245,7 +245,8 @@ async fn run_compaction( ProcessType::Compactor, Some(channel_id.clone()), deps.event_tx.clone(), - ); + ) + .with_secret_scan_mode(deps.secret_scan_mode()); let mut compaction_history = Vec::new(); let response = hook diff --git a/src/agent/cortex.rs b/src/agent/cortex.rs index 00f54aee1..34285c9a4 100644 --- a/src/agent/cortex.rs +++ b/src/agent/cortex.rs @@ -2428,18 +2428,16 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho let agent_names = deps.agent_names.clone(); let sqlite_pool = deps.sqlite_pool.clone(); let secrets_snapshot = deps.runtime_config.secrets.load().clone(); + let scan_mode = deps.secret_scan_mode(); let process_control_registry = deps.process_control_registry.clone(); let runtime_config = deps.runtime_config.clone(); tokio::spawn(async move { // Scrub known secrets and unknown leak patterns from all worker output // before persisting, logging, or emitting 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) + let store_ref: Option<&crate::secrets::store::SecretsStore> = + secrets_snapshot.as_ref().as_ref().map(|s| s.as_ref()); + scan_mode.apply_scrubbing_with_store(&text, store_ref) }; let worker_execution = async { diff --git a/src/agent/cortex_chat.rs b/src/agent/cortex_chat.rs index 575bd85cd..f464a4d18 100644 --- a/src/agent/cortex_chat.rs +++ b/src/agent/cortex_chat.rs @@ -341,7 +341,8 @@ impl CortexChatSession { ProcessType::Cortex, channel_context_id.map(std::sync::Arc::::from), self.deps.event_tx.clone(), - ); + ) + .with_secret_scan_mode(self.deps.secret_scan_mode()); let hook = CortexChatHook::new(event_tx.clone(), spacebot_hook); // Clone what the spawned task needs diff --git a/src/agent/ingestion.rs b/src/agent/ingestion.rs index 9ffb7eb78..e3747d465 100644 --- a/src/agent/ingestion.rs +++ b/src/agent/ingestion.rs @@ -498,7 +498,8 @@ async fn process_chunk( ProcessType::Branch, None, deps.event_tx.clone(), - ); + ) + .with_secret_scan_mode(deps.secret_scan_mode()); let user_prompt = prompt_engine.render_system_ingestion_chunk(filename, chunk_number, total_chunks, chunk)?; diff --git a/src/agent/worker.rs b/src/agent/worker.rs index 710c3a37f..cb91692d9 100644 --- a/src/agent/worker.rs +++ b/src/agent/worker.rs @@ -85,7 +85,9 @@ impl Worker { ProcessType::Worker, channel_id.clone(), deps.event_tx.clone(), - ); + ) + .with_secret_scan_mode(deps.secret_scan_mode()) + .with_secrets_snapshot(deps.runtime_config.secrets.load().as_ref().clone()); let (status_tx, status_rx) = watch::channel("starting".to_string()); Self { diff --git a/src/hooks/spacebot.rs b/src/hooks/spacebot.rs index 6ea610021..2e87107af 100644 --- a/src/hooks/spacebot.rs +++ b/src/hooks/spacebot.rs @@ -35,6 +35,13 @@ pub struct SpacebotHook { channel_id: Option, event_tx: broadcast::Sender, tool_nudge_policy: ToolNudgePolicy, + /// Controls whether regex-based leak detection runs on tool output. + /// When `OwnSecretsOnly`, only exact-match scrubbing of stored secrets + /// is performed — regex patterns are skipped to avoid false positives + /// on public API keys found in scraped web content. + secret_scan_mode: crate::secrets::scrub::SecretScanMode, + /// Snapshot of the secrets store for exact-match scrubbing on event payloads. + secrets_snapshot: Option>, completion_calls: std::sync::Arc, saw_tool_call: std::sync::Arc, nudge_request_active: std::sync::Arc, @@ -63,12 +70,29 @@ impl SpacebotHook { channel_id, event_tx, tool_nudge_policy: ToolNudgePolicy::for_process(process_type), + secret_scan_mode: crate::secrets::scrub::SecretScanMode::default(), + secrets_snapshot: None, completion_calls: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)), saw_tool_call: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), nudge_request_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), } } + /// Override the secret scan mode for this hook. + pub fn with_secret_scan_mode(mut self, mode: crate::secrets::scrub::SecretScanMode) -> Self { + self.secret_scan_mode = mode; + self + } + + /// Set the secrets store snapshot for exact-match scrubbing on event payloads. + pub fn with_secrets_snapshot( + mut self, + store: Option>, + ) -> Self { + self.secrets_snapshot = store; + self + } + /// Override the default process-scoped nudge policy. pub fn with_tool_nudge_policy(mut self, policy: ToolNudgePolicy) -> Self { self.tool_nudge_policy = policy; @@ -244,9 +268,20 @@ impl SpacebotHook { /// Scan content for potential secret leaks, including encoded forms. /// - /// Delegates to the shared implementation in `secrets::scrub`. + /// Respects the configured `SecretScanMode`: + /// - `Strict`: full regex-based detection (default). + /// - `OwnSecretsOnly`: skips regex detection — own secrets are already + /// redacted by the `StreamScrubber` before this runs, so no further + /// scanning is needed. + /// - `Disabled`: no scanning at all. fn scan_for_leaks(&self, content: &str) -> Option { - crate::secrets::scrub::scan_for_leaks(content) + match self.secret_scan_mode { + crate::secrets::scrub::SecretScanMode::Strict => { + crate::secrets::scrub::scan_for_leaks(content) + } + crate::secrets::scrub::SecretScanMode::OwnSecretsOnly + | crate::secrets::scrub::SecretScanMode::Disabled => None, + } } /// Apply shared safety checks for tool output before any downstream handling. @@ -549,7 +584,10 @@ where // 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 scrubbed = self.secret_scan_mode.apply_scrubbing_with_store( + result, + self.secrets_snapshot.as_ref().map(|arc| arc.as_ref()), + ); let capped = crate::tools::truncate_output(&scrubbed, crate::tools::MAX_TOOL_OUTPUT_BYTES); self.emit_tool_completed_event_from_capped(tool_name, capped); @@ -915,4 +953,41 @@ mod tests { } if text_delta == "hi" && aggregated_text == "hi" )); } + + #[test] + fn own_secrets_only_mode_skips_regex_leak_detection() { + let hook = make_hook().with_secret_scan_mode( + crate::secrets::scrub::SecretScanMode::OwnSecretsOnly, + ); + // An API key pattern that would normally trigger detection + let content = "found key sk-ant-abc123456789012345678 in page"; + assert!( + hook.scan_for_leaks(content).is_none(), + "own_secrets_only mode should skip regex detection" + ); + } + + #[test] + fn strict_mode_detects_regex_leaks() { + let hook = make_hook().with_secret_scan_mode( + crate::secrets::scrub::SecretScanMode::Strict, + ); + let content = "found key sk-ant-abc123456789012345678 in page"; + assert!( + hook.scan_for_leaks(content).is_some(), + "strict mode should detect regex-matched leaks" + ); + } + + #[test] + fn disabled_mode_skips_all_leak_detection() { + let hook = make_hook().with_secret_scan_mode( + crate::secrets::scrub::SecretScanMode::Disabled, + ); + let content = "found key sk-ant-abc123456789012345678 in page"; + assert!( + hook.scan_for_leaks(content).is_none(), + "disabled mode should skip all leak detection" + ); + } } diff --git a/src/lib.rs b/src/lib.rs index b8cd6f3c3..9b974e637 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -363,6 +363,11 @@ impl AgentDeps { pub fn routing(&self) -> arc_swap::Guard> { self.runtime_config.routing.load() } + + /// Read the current secret scan mode from the sandbox config. + pub fn secret_scan_mode(&self) -> secrets::scrub::SecretScanMode { + self.runtime_config.sandbox.load().secret_scanner + } } /// A running agent instance with all its isolated resources. diff --git a/src/opencode/worker.rs b/src/opencode/worker.rs index cbad04eb3..5d222ea03 100644 --- a/src/opencode/worker.rs +++ b/src/opencode/worker.rs @@ -33,6 +33,8 @@ pub struct OpenCodeWorker { pub model: Option, /// Secrets store for exact-match scrubbing of tool secret values in SSE output. pub secrets_store: Option>, + /// Controls whether regex-based leak detection runs on worker output. + pub secret_scan_mode: crate::secrets::scrub::SecretScanMode, } /// Result of an OpenCode worker run. @@ -63,6 +65,7 @@ impl OpenCodeWorker { system_prompt: None, model: None, secrets_store: None, + secret_scan_mode: crate::secrets::scrub::SecretScanMode::default(), } } @@ -99,6 +102,12 @@ impl OpenCodeWorker { self } + /// Set the secret scan mode for this worker. + pub fn with_secret_scan_mode(mut self, mode: crate::secrets::scrub::SecretScanMode) -> Self { + self.secret_scan_mode = mode; + self + } + /// Scrub tool secret values from text, replacing each with `[REDACTED:]`. /// Returns the scrubbed text. If no secrets store is set, returns the input unchanged. fn scrub_text(&self, text: &str) -> String { @@ -341,12 +350,14 @@ impl OpenCodeWorker { // before leak detection so they don't trigger false positives. let scrubbed = self.scrub_text(text); - if let Some(leak) = crate::secrets::scrub::scan_for_leaks(&scrubbed) { - tracing::warn!( - worker_id = %self.id, - leak_prefix = %&leak[..leak.len().min(8)], - "potential secret detected in OpenCode worker output" - ); + if self.secret_scan_mode == crate::secrets::scrub::SecretScanMode::Strict { + if let Some(leak) = crate::secrets::scrub::scan_for_leaks(&scrubbed) { + tracing::warn!( + worker_id = %self.id, + leak_prefix = %&leak[..leak.len().min(8)], + "potential secret detected in OpenCode worker output" + ); + } } *last_text = scrubbed; @@ -378,15 +389,19 @@ impl OpenCodeWorker { // user-visible leak blocking. if let Some(tool_output) = output { let scrubbed = self.scrub_text(tool_output); - if let Some(leak) = - crate::secrets::scrub::scan_for_leaks(&scrubbed) + if self.secret_scan_mode + == crate::secrets::scrub::SecretScanMode::Strict { - tracing::warn!( - worker_id = %self.id, - tool = %tool_name, - leak_prefix = %&leak[..leak.len().min(8)], - "potential secret detected in OpenCode tool output" - ); + if let Some(leak) = + crate::secrets::scrub::scan_for_leaks(&scrubbed) + { + tracing::warn!( + worker_id = %self.id, + tool = %tool_name, + leak_prefix = %&leak[..leak.len().min(8)], + "potential secret detected in OpenCode tool output" + ); + } } } diff --git a/src/sandbox.rs b/src/sandbox.rs index 0a5fb51ae..91bb75a14 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -26,6 +26,16 @@ pub struct SandboxConfig { /// in the store. The field is additive either way. #[serde(default)] pub passthrough_env: Vec, + /// Controls how the secret leak scanner operates on tool output. + /// + /// - `strict` (default): regex-based detection for unknown API key patterns. + /// Catches secrets not in the store but may false-positive on public keys + /// found in scraped web content (e.g. Algolia search keys). + /// - `own_secrets_only`: only redact the agent's own stored secrets. Skips + /// regex detection entirely — eliminates false positives from web scraping. + /// - `disabled`: no leak detection at all. + #[serde(default)] + pub secret_scanner: crate::secrets::scrub::SecretScanMode, } impl Default for SandboxConfig { @@ -34,6 +44,7 @@ impl Default for SandboxConfig { mode: SandboxMode::Enabled, writable_paths: Vec::new(), passthrough_env: Vec::new(), + secret_scanner: crate::secrets::scrub::SecretScanMode::default(), } } } diff --git a/src/secrets/scrub.rs b/src/secrets/scrub.rs index 3ffc354a3..56b2a39e2 100644 --- a/src/secrets/scrub.rs +++ b/src/secrets/scrub.rs @@ -12,8 +12,96 @@ //! redacted), and leak detection only fires on unknown/unstored secrets. use regex::Regex; +use serde::{Deserialize, Serialize}; use std::sync::LazyLock; +/// Controls how aggressively the leak scanner operates. +/// +/// `Strict` (default): checks tool output against hardcoded API key regex +/// patterns. Catches unknown secrets but can false-positive on legitimate +/// public keys found in scraped web content (e.g. Algolia search keys). +/// +/// `OwnSecretsOnly`: skips regex-based leak detection entirely. Only the +/// exact-match `StreamScrubber` (layer 1) redacts the agent's own stored +/// secrets. This eliminates false positives from web scraping at the cost +/// of not detecting unknown/unstored secrets in tool output. +/// +/// `Disabled`: no leak detection at all. Use with caution. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum SecretScanMode { + /// Regex-based leak detection for unknown secrets (default). + #[default] + Strict, + /// Only redact secrets stored in the agent's secret store. + /// Eliminates false positives from scraped web content. + OwnSecretsOnly, + /// No regex-based leak detection. Exact-match scrubbing of stored + /// secrets (Layer 1) still runs. Use with caution. + Disabled, +} + +impl SecretScanMode { + /// Apply regex-based leak scrubbing only when in `Strict` mode. + /// Returns the scrubbed text in Strict mode, or the input unchanged otherwise. + pub fn maybe_scrub_leaks(&self, text: String) -> String { + match self { + Self::Strict => scrub_leaks(&text), + Self::OwnSecretsOnly | Self::Disabled => text, + } + } + + /// Centralized scrubbing using a `SecretsStore` reference. + /// + /// Enforces mode semantics end-to-end: + /// - `Strict`: exact-match (layer 1) + regex (layer 2). + /// - `OwnSecretsOnly`: exact-match only (layer 1). + /// - `Disabled`: no scrubbing at all. + pub fn apply_scrubbing_with_store( + &self, + text: &str, + store: Option<&crate::secrets::store::SecretsStore>, + ) -> String { + match self { + Self::Disabled => text.to_string(), + Self::OwnSecretsOnly => { + if let Some(store) = store { + scrub_with_store(text, store) + } else { + text.to_string() + } + } + Self::Strict => { + let scrubbed = if let Some(store) = store { + scrub_with_store(text, store) + } else { + text.to_string() + }; + scrub_leaks(&scrubbed) + } + } + } + + /// Centralized scrubbing using explicit secret name/value pairs. + /// + /// Same mode semantics as `apply_scrubbing_with_store` but accepts + /// pre-extracted pairs instead of a store reference. + pub fn apply_scrubbing_with_pairs( + &self, + text: &str, + pairs: &[(String, String)], + ) -> String { + match self { + Self::Disabled => text.to_string(), + Self::OwnSecretsOnly => scrub_secrets(text, pairs), + Self::Strict => { + let scrubbed = scrub_secrets(text, pairs); + scrub_leaks(&scrubbed) + } + } + } +} + /// Regex patterns for known API key formats. Used by `scan_for_leaks()` to /// detect secrets that aren't in the store. static LEAK_PATTERNS: LazyLock> = LazyLock::new(|| { @@ -380,4 +468,68 @@ mod tests { "surrounding text should be preserved in: {result}" ); } + + #[test] + fn secret_scan_mode_defaults_to_strict() { + assert_eq!(SecretScanMode::default(), SecretScanMode::Strict); + } + + #[test] + fn secret_scan_mode_deserializes_from_toml() { + #[derive(Deserialize)] + struct Config { + mode: SecretScanMode, + } + let strict: Config = toml::from_str(r#"mode = "strict""#).unwrap(); + assert_eq!(strict.mode, SecretScanMode::Strict); + + let own: Config = toml::from_str(r#"mode = "own_secrets_only""#).unwrap(); + assert_eq!(own.mode, SecretScanMode::OwnSecretsOnly); + + let disabled: Config = toml::from_str(r#"mode = "disabled""#).unwrap(); + assert_eq!(disabled.mode, SecretScanMode::Disabled); + } + + #[test] + fn apply_scrubbing_with_pairs_strict_mode() { + let pairs = vec![("TOKEN".into(), "my-secret-token".into())]; + let input = "key: my-secret-token and sk-ant-abc123456789012345678"; + let result = SecretScanMode::Strict.apply_scrubbing_with_pairs(input, &pairs); + // Layer 1: exact-match redaction + assert!( + result.contains("[REDACTED:TOKEN]"), + "expected exact-match redaction in: {result}" + ); + // Layer 2: regex-based redaction + assert!( + result.contains("[LEAKED_SECRET_REDACTED]"), + "expected regex redaction in: {result}" + ); + } + + #[test] + fn apply_scrubbing_with_pairs_own_secrets_only_mode() { + let pairs = vec![("TOKEN".into(), "my-secret-token".into())]; + let input = "key: my-secret-token and sk-ant-abc123456789012345678"; + let result = SecretScanMode::OwnSecretsOnly.apply_scrubbing_with_pairs(input, &pairs); + // Layer 1: exact-match redaction should run + assert!( + result.contains("[REDACTED:TOKEN]"), + "expected exact-match redaction in: {result}" + ); + // Layer 2: regex-based redaction should NOT run + assert!( + result.contains("sk-ant-abc123456789012345678"), + "regex leak pattern should pass through in OwnSecretsOnly: {result}" + ); + } + + #[test] + fn apply_scrubbing_with_pairs_disabled_mode() { + let pairs = vec![("TOKEN".into(), "my-secret-token".into())]; + let input = "key: my-secret-token and sk-ant-abc123456789012345678"; + let result = SecretScanMode::Disabled.apply_scrubbing_with_pairs(input, &pairs); + // No scrubbing at all + assert_eq!(result, input, "disabled mode should return input unchanged"); + } } diff --git a/src/tools.rs b/src/tools.rs index a87ee2cf3..e47b19e23 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -301,14 +301,24 @@ pub async fn add_channel_tools( .cloned() .unwrap_or_else(|| state.deps.agent_id.to_string()); handle - .add_tool(ReplyTool::new( - response_tx.clone(), - conversation_id.clone(), - state.conversation_logger.clone(), - state.channel_id.clone(), - replied_flag.clone(), - agent_display_name, - )) + .add_tool( + ReplyTool::new( + response_tx.clone(), + conversation_id.clone(), + state.conversation_logger.clone(), + state.channel_id.clone(), + replied_flag.clone(), + agent_display_name, + ) + .with_secret_scan_mode(state.deps.secret_scan_mode()) + .with_tool_secrets({ + let guard = state.deps.runtime_config.secrets.load(); + match guard.as_ref() { + Some(store) => store.tool_secret_pairs(), + None => Vec::new(), + } + }), + ) .await?; } handle.add_tool(BranchTool::new(state.clone())).await?; @@ -477,7 +487,7 @@ pub fn create_worker_tool_server( if let Some(store) = runtime_config.secrets.load().as_ref() { status_tool = status_tool.with_tool_secrets(store.tool_secret_pairs()); } - status_tool + status_tool.with_secret_scan_mode(runtime_config.sandbox.load().secret_scanner) }) .tool(ReadSkillTool::new(runtime_config.clone())); diff --git a/src/tools/reply.rs b/src/tools/reply.rs index 91c68b2e9..bc2b17113 100644 --- a/src/tools/reply.rs +++ b/src/tools/reply.rs @@ -46,6 +46,9 @@ pub struct ReplyTool { channel_id: ChannelId, replied_flag: RepliedFlag, agent_display_name: String, + /// Tool secret pairs for exact-match redaction on reply content. + tool_secret_pairs: Vec<(String, String)>, + secret_scan_mode: crate::secrets::scrub::SecretScanMode, } impl ReplyTool { @@ -65,8 +68,22 @@ impl ReplyTool { channel_id, replied_flag, agent_display_name: agent_display_name.into(), + tool_secret_pairs: Vec::new(), + secret_scan_mode: crate::secrets::scrub::SecretScanMode::default(), } } + + /// Set the secret scan mode for this reply tool. + pub fn with_secret_scan_mode(mut self, mode: crate::secrets::scrub::SecretScanMode) -> Self { + self.secret_scan_mode = mode; + self + } + + /// Set tool secret pairs for exact-match redaction on reply content. + pub fn with_tool_secrets(mut self, pairs: Vec<(String, String)>) -> Self { + self.tool_secret_pairs = pairs; + self + } } /// Error type for reply tool. @@ -378,15 +395,22 @@ impl Tool for ReplyTool { .map(|name| name.trim()) .filter(|name| !name.is_empty()); - if let Some(leak) = crate::secrets::scrub::scan_for_leaks(&converted_content) { - tracing::error!( - conversation_id = %self.conversation_id, - leak_prefix = %&leak[..leak.len().min(8)], - "reply tool blocked content matching secret pattern" - ); - return Err(ReplyError( - "blocked reply content: potential secret detected".into(), - )); + // Apply centralized scrubbing: exact-match (layer 1) + regex (layer 2) per mode. + let converted_content = self + .secret_scan_mode + .apply_scrubbing_with_pairs(&converted_content, &self.tool_secret_pairs); + + if self.secret_scan_mode == crate::secrets::scrub::SecretScanMode::Strict { + if let Some(leak) = crate::secrets::scrub::scan_for_leaks(&converted_content) { + tracing::error!( + conversation_id = %self.conversation_id, + leak_prefix = %&leak[..leak.len().min(8)], + "reply tool blocked content matching secret pattern" + ); + return Err(ReplyError( + "blocked reply content: potential secret detected".into(), + )); + } } let response = if let Some(name) = thread_name { diff --git a/src/tools/set_status.rs b/src/tools/set_status.rs index 23633de2e..d14fc152c 100644 --- a/src/tools/set_status.rs +++ b/src/tools/set_status.rs @@ -16,6 +16,7 @@ pub struct SetStatusTool { event_tx: broadcast::Sender, /// Tool secret pairs for scrubbing status text before it reaches the channel. tool_secret_pairs: Vec<(String, String)>, + secret_scan_mode: crate::secrets::scrub::SecretScanMode, } impl SetStatusTool { @@ -32,6 +33,7 @@ impl SetStatusTool { channel_id, event_tx, tool_secret_pairs: Vec::new(), + secret_scan_mode: crate::secrets::scrub::SecretScanMode::default(), } } @@ -40,6 +42,12 @@ impl SetStatusTool { self.tool_secret_pairs = pairs; self } + + /// Set the secret scan mode for this tool. + pub fn with_secret_scan_mode(mut self, mode: crate::secrets::scrub::SecretScanMode) -> Self { + self.secret_scan_mode = mode; + self + } } /// Error type for set status tool. @@ -100,11 +108,10 @@ impl Tool for SetStatusTool { args.status }; - // 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); + // Apply centralized scrubbing: exact-match (layer 1) + regex (layer 2) per mode. + let status = self + .secret_scan_mode + .apply_scrubbing_with_pairs(&status, &self.tool_secret_pairs); let event = ProcessEvent::WorkerStatus { agent_id: self.agent_id.clone(),