From 6d19dade061dad5377c0007a29f35f3ac9fbc2af Mon Sep 17 00:00:00 2001 From: Victor Sumner Date: Sun, 19 Apr 2026 11:12:44 -0400 Subject: [PATCH 1/2] fix: redact working-memory sensitive text --- docs/design-docs/working-memory-triage.md | 8 +- src/agent/channel_dispatch.rs | 63 +++++++++-- src/cron/scheduler.rs | 66 +++++++++++- src/secrets/scrub.rs | 126 ++++++++++++++++++++++ 4 files changed, 249 insertions(+), 14 deletions(-) diff --git a/docs/design-docs/working-memory-triage.md b/docs/design-docs/working-memory-triage.md index 762dce88e..eebd01765 100644 --- a/docs/design-docs/working-memory-triage.md +++ b/docs/design-docs/working-memory-triage.md @@ -17,8 +17,8 @@ Findings from CodeRabbit review + bug reports. Tracking resolution before merge. - [x] **R3 — Don't exclude participant-role facts yet** (`prompts/en/cortex_knowledge_synthesis.md.j2:21`) Exclusion of "The user is the CEO" drops participant context with nowhere else to live until Phase 6 ships. **Fixed in this slice:** knowledge synthesis now preserves concise participant/user role facts when they affect future routing, authority, relationships, or interpretation. -- [ ] **R4 — Raw worker task in working memory** (`src/agent/channel_dispatch.rs:596`) - `task` from user input persisted verbatim; could capture secrets/PII. Truncate and scrub. +- [x] **R4 — Raw worker task in working memory** (`src/agent/channel_dispatch.rs:596`) + `task` from user input persisted verbatim; could capture secrets/PII. **Fixed in this slice:** worker-spawn working-memory text now uses shared secret redaction and char-safe truncation. - [ ] **R5 — Dirty flag only bumps on merges** (`src/agent/cortex.rs:1958`) Prunes and decays also change the memory set but don't trigger knowledge synthesis re-gen. Add `report.pruned > 0 || report.decayed > 0`. **Partial in PR #570:** prunes and merges now dirty synthesis; decay remains intentionally importance-only and needs a follow-up decision. @@ -44,8 +44,8 @@ Findings from CodeRabbit review + bug reports. Tracking resolution before merge. - [ ] **R12 — Silent error swallowing in inspect_prompt** (`src/api/channels.rs:649`) `unwrap_or_default()` / `.ok()` hides DB/template errors. Log and propagate per coding guidelines. -- [ ] **R13 — Raw error strings in working memory** (`src/cron/scheduler.rs:386`) - Full error text persisted; could contain sensitive internals. Emit redacted summary only. +- [x] **R13 — Raw error strings in working memory** (`src/cron/scheduler.rs:386`) + Full error text persisted; could contain sensitive internals. **Fixed in this slice:** cron error working-memory text now uses the same redacted, bounded summary while tracing keeps the full error. - [ ] **R14 — Timezone fallback drops valid `cron_timezone`** (`src/main.rs:2559`) If `user_timezone` is present but unparseable, `cron_timezone` is never tried. Parse each independently. diff --git a/src/agent/channel_dispatch.rs b/src/agent/channel_dispatch.rs index 30402c119..5087457fd 100644 --- a/src/agent/channel_dispatch.rs +++ b/src/agent/channel_dispatch.rs @@ -40,6 +40,8 @@ enum WorkerCompletionKind { Failed, } +const WORKING_MEMORY_TASK_MAX_CHARS: usize = 500; + #[derive(Debug, Clone)] pub(crate) enum WorkerCompletionError { Cancelled { reason: String }, @@ -99,6 +101,14 @@ pub(crate) fn map_worker_completion_result( (result_text, notify, success) } +fn sanitize_worker_memory_task(task: &str, tool_secret_pairs: &[(String, String)]) -> String { + crate::secrets::scrub::scrub_working_memory_text( + task, + tool_secret_pairs, + WORKING_MEMORY_TASK_MAX_CHARS, + ) +} + /// Build the worker status text (time + system info) used in worker system prompts. /// /// Centralises the `SystemInfo` + `TemporalContext` assembly so every worker @@ -597,9 +607,9 @@ async fn spawn_worker_inner( let sandbox_write_allowlist = state.deps.sandbox.prompt_write_allowlist(); // Collect tool secret names so the worker template can list available credentials. let secrets_guard = rc.secrets.load(); - let tool_secret_names = match (*secrets_guard).as_ref() { - Some(store) => store.tool_secret_names(), - None => Vec::new(), + let (tool_secret_names, tool_secret_pairs) = match (*secrets_guard).as_ref() { + Some(store) => (store.tool_secret_names(), store.tool_secret_pairs()), + None => (Vec::new(), Vec::new()), }; let browser_config = (**rc.browser_config.load()).clone(); @@ -793,12 +803,13 @@ async fn spawn_worker_inner( }) .ok(); + let memory_task = sanitize_worker_memory_task(task, &tool_secret_pairs); state .deps .working_memory .emit( crate::memory::WorkingMemoryEventType::WorkerSpawned, - format!("Worker spawned: {task}"), + format!("Worker spawned: {memory_task}"), ) .channel(state.channel_id.to_string()) .importance(0.6) @@ -872,6 +883,9 @@ async fn spawn_opencode_worker_inner( let persist_directory = directory.clone(); let oc_secrets_store = state.deps.runtime_config.secrets.load().as_ref().clone(); + let oc_tool_secret_pairs = oc_secrets_store + .as_ref() + .map_or_else(Vec::new, |store| store.tool_secret_pairs()); // Build temporal/status context so OpenCode workers get the same system // info (time, model, context window) as builtin workers. @@ -996,12 +1010,13 @@ async fn spawn_opencode_worker_inner( }) .ok(); + let memory_task = sanitize_worker_memory_task(task, &oc_tool_secret_pairs); state .deps .working_memory .emit( crate::memory::WorkingMemoryEventType::WorkerSpawned, - format!("Worker spawned (opencode): {task}"), + format!("Worker spawned (opencode): {memory_task}"), ) .channel(state.channel_id.to_string()) .importance(0.6) @@ -1396,7 +1411,10 @@ fn expand_tilde(path: &str) -> std::path::PathBuf { #[cfg(test)] mod tests { - use super::{WorkerCompletionError, map_worker_completion_result, spawn_worker_task}; + use super::{ + WORKING_MEMORY_TASK_MAX_CHARS, WorkerCompletionError, map_worker_completion_result, + sanitize_worker_memory_task, spawn_worker_task, + }; use crate::{ProcessEvent, WorkerId}; use std::sync::Arc; use std::time::Duration; @@ -1414,6 +1432,39 @@ mod tests { assert!(!success); } + #[test] + fn worker_spawned_memory_task_redacts_secrets() { + let tool_secret_pairs = vec![("API_KEY".to_string(), "stored-secret".to_string())]; + let task = "use stored-secret and sk-ant-abc123456789012345678"; + let result = sanitize_worker_memory_task(task, &tool_secret_pairs); + + assert!( + !result.contains("stored-secret"), + "stored secret should be redacted in: {result}" + ); + assert!( + !result.contains("sk-ant-"), + "leak pattern should be redacted in: {result}" + ); + assert!( + result.contains("[REDACTED:API_KEY]"), + "stored secret marker missing in: {result}" + ); + assert!( + result.contains("[LEAKED_SECRET_REDACTED]"), + "leak marker missing in: {result}" + ); + } + + #[test] + fn worker_spawned_memory_task_is_bounded() { + let task = "a".repeat(WORKING_MEMORY_TASK_MAX_CHARS + 100); + let result = sanitize_worker_memory_task(&task, &[]); + + assert_eq!(result.chars().count(), WORKING_MEMORY_TASK_MAX_CHARS); + assert!(result.ends_with(" ... [truncated]")); + } + #[tokio::test] async fn spawn_worker_task_emits_cancelled_completion_event() { let (event_tx, mut event_rx) = broadcast::channel(8); diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index cd4dfb12d..9e3d60c7c 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -130,6 +130,7 @@ pub struct CronContext { } const MAX_CONSECUTIVE_FAILURES: u32 = 3; +const WORKING_MEMORY_CRON_ERROR_MAX_CHARS: usize = 500; /// RAII guard that clears an `AtomicBool` on drop, ensuring the flag is /// released even if the holding task panics. @@ -153,7 +154,12 @@ fn emit_cron_error( failure_class: &'static str, error: &crate::error::Error, ) { - let message = format!("Cron {failure_class}: {job_id}: {error}"); + let secrets_guard = context.deps.runtime_config.secrets.load(); + let tool_secret_pairs = match (*secrets_guard).as_ref() { + Some(store) => store.tool_secret_pairs(), + None => Vec::new(), + }; + let message = cron_error_memory_message(job_id, failure_class, error, &tool_secret_pairs); // Emit to working memory for agent context awareness context @@ -167,6 +173,19 @@ fn emit_cron_error( tracing::error!(cron_id = %job_id, failure_class, %error, "cron job execution failed"); } +fn cron_error_memory_message( + job_id: &str, + failure_class: &'static str, + error: &crate::error::Error, + tool_secret_pairs: &[(String, String)], +) -> String { + crate::secrets::scrub::scrub_working_memory_text( + &format!("Cron {failure_class}: {job_id}: {error}"), + tool_secret_pairs, + WORKING_MEMORY_CRON_ERROR_MAX_CHARS, + ) +} + #[derive(Debug)] enum CronRunError { Execution(crate::error::Error), @@ -1730,9 +1749,11 @@ fn normalize_cron_delivery_response(response: OutboundResponse) -> Option String { result } +/// Scrub and bound text before storing it in working memory. +/// +/// Working memory is replayed into future LLM context, so it must not persist +/// exact tool secrets, known plaintext leak patterns, or unbounded user/error +/// payloads. +pub fn scrub_working_memory_text( + text: &str, + tool_secrets: &[(String, String)], + max_chars: usize, +) -> String { + let scrubbed = scrub_secrets(text, tool_secrets); + let scrubbed = scrub_leaks(&scrubbed); + let scrubbed = redact_working_memory_encoded_leaks(&scrubbed); + truncate_for_working_memory(&scrubbed, max_chars) +} + +fn redact_working_memory_encoded_leaks(text: &str) -> String { + if scan_for_leaks(text).is_some() { + return "[WORKING_MEMORY_REDACTED:encoded-secret]".to_string(); + } + + text.to_string() +} + +fn truncate_for_working_memory(text: &str, max_chars: usize) -> String { + const TRUNCATED_SUFFIX: &str = " ... [truncated]"; + + if text.chars().count() <= max_chars { + return text.to_string(); + } + + let suffix_len = TRUNCATED_SUFFIX.chars().count(); + if max_chars <= suffix_len { + return TRUNCATED_SUFFIX.chars().take(max_chars).collect(); + } + + let kept_chars = max_chars - suffix_len; + let mut result: String = text.chars().take(kept_chars).collect(); + result.push_str(TRUNCATED_SUFFIX); + result +} + #[cfg(test)] mod tests { use super::*; @@ -380,4 +422,88 @@ mod tests { "surrounding text should be preserved in: {result}" ); } + + #[test] + fn scrub_working_memory_text_redacts_exact_and_pattern_secrets() { + let tool_secrets = vec![("API_KEY".to_string(), "stored-secret".to_string())]; + let input = "stored-secret and sk-ant-abc123456789012345678"; + let result = scrub_working_memory_text(input, &tool_secrets, 200); + + assert!( + !result.contains("stored-secret"), + "stored secret should be redacted in: {result}" + ); + assert!( + !result.contains("sk-ant-"), + "leak pattern should be redacted in: {result}" + ); + assert!( + result.contains("[REDACTED:API_KEY]"), + "exact redaction marker missing in: {result}" + ); + assert!( + result.contains("[LEAKED_SECRET_REDACTED]"), + "leak redaction marker missing in: {result}" + ); + } + + #[test] + fn scrub_working_memory_text_truncates_on_character_boundaries() { + let input = "é".repeat(100); + let result = scrub_working_memory_text(&input, &[], 20); + + assert_eq!(result.chars().count(), 20); + assert!(result.ends_with(" ... [truncated]")); + assert!(std::str::from_utf8(result.as_bytes()).is_ok()); + } + + #[test] + fn scrub_working_memory_text_fails_closed_for_url_encoded_secret() { + let secret = "sk-ant-abc123456789012345678"; + let input = "worker task has sk%2Dant%2Dabc123456789012345678 and context"; + let result = scrub_working_memory_text(input, &[], 200); + + assert_eq!(result, "[WORKING_MEMORY_REDACTED:encoded-secret]"); + assert!( + !result.contains(secret), + "decoded secret should not appear in: {result}" + ); + assert!( + !result.contains("sk%2Dant"), + "encoded secret should not appear in: {result}" + ); + assert!(scan_for_leaks(&result).is_none()); + } + + #[test] + fn scrub_working_memory_text_fails_closed_for_base64_encoded_secret() { + use base64::Engine as _; + + let secret = "sk-ant-abc123456789012345678"; + let encoded = base64::engine::general_purpose::STANDARD.encode(secret); + let input = format!("cron error included {encoded}"); + let result = scrub_working_memory_text(&input, &[], 200); + + assert_eq!(result, "[WORKING_MEMORY_REDACTED:encoded-secret]"); + assert!( + !result.contains(&encoded), + "encoded secret should not appear in: {result}" + ); + assert!(scan_for_leaks(&result).is_none()); + } + + #[test] + fn scrub_working_memory_text_fails_closed_for_hex_encoded_secret() { + let secret = "sk-ant-abc123456789012345678"; + let encoded = hex::encode(secret); + let input = format!("cron error included {encoded}"); + let result = scrub_working_memory_text(&input, &[], 200); + + assert_eq!(result, "[WORKING_MEMORY_REDACTED:encoded-secret]"); + assert!( + !result.contains(&encoded), + "encoded secret should not appear in: {result}" + ); + assert!(scan_for_leaks(&result).is_none()); + } } From 2128fea2ce3d117e0d83dab4c8a98982b6ec29e8 Mon Sep 17 00:00:00 2001 From: Victor Sumner Date: Mon, 20 Apr 2026 07:49:38 -0400 Subject: [PATCH 2/2] test(cron): cover encoded secret redaction --- src/cron/scheduler.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index 9e3d60c7c..dced1a1ac 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -2021,6 +2021,8 @@ mod tests { #[test] fn cron_error_memory_message_redacts_and_bounds_error_text() { + use base64::Engine as _; + let tool_secret_pairs = vec![("API_KEY".to_string(), "stored-secret".to_string())]; let error = crate::error::Error::Other(anyhow::anyhow!( "{} {} {}", @@ -2054,6 +2056,29 @@ mod tests { ); assert_eq!(message.chars().count(), WORKING_MEMORY_CRON_ERROR_MAX_CHARS); assert!(message.ends_with(" ... [truncated]")); + + let encoded_secret = + base64::engine::general_purpose::STANDARD.encode("sk-ant-abc123456789012345678"); + let encoded_error = crate::error::Error::Other(anyhow::anyhow!( + "{} {}", + encoded_secret, + "x".repeat(WORKING_MEMORY_CRON_ERROR_MAX_CHARS) + )); + let encoded_message = cron_error_memory_message( + "daily-digest", + "execution_error", + &encoded_error, + &tool_secret_pairs, + ); + + assert!( + encoded_message.contains("[WORKING_MEMORY_REDACTED:encoded-secret]"), + "encoded leak marker missing in: {encoded_message}" + ); + assert!( + !encoded_message.contains(&encoded_secret), + "encoded secret should be redacted in: {encoded_message}" + ); } #[tokio::test]