Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/agent/branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 17 additions & 1 deletion src/agent/channel_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
agent_id,
branch_id,
channel_id,
conclusion: format!("Branch failed: {error}"),
conclusion,
});
}
Comment on lines 261 to 281

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch scrubbing the branch failure path.

One thing: this only applies scrub_leaks(). For consistency with the other egress paths in this PR (and to cover secrets that are in the store), it’d be good to also apply scrub_with_store() here. Since branch.run() consumes branch, you can snapshot secrets before calling it.

Suggested change
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).
let conclusion =
crate::secrets::scrub::scrub_leaks(&format!("Branch failed: {error}"));
let _ = event_tx.send(crate::ProcessEvent::BranchResult {
agent_id,
branch_id,
channel_id,
conclusion: format!("Branch failed: {error}"),
conclusion,
});
}
async move {
let secrets_snapshot = branch.deps.runtime_config.secrets.load().clone();
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).
let raw = format!("Branch failed: {error}");
let scrubbed = 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(&scrubbed);
let _ = event_tx.send(crate::ProcessEvent::BranchResult {
agent_id,
branch_id,
channel_id,
conclusion,
});
}
}

}
Expand Down Expand Up @@ -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);
Comment on lines +606 to +613

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Worker completion redaction still uses a spawn-time store snapshot.

Layer 1 here depends on secrets_store captured before the worker starts. If secrets change during execution, exact-match redaction can miss updated values.

Also applies to: 618-619

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/agent/channel_dispatch.rs` around lines 598 - 605, The redaction uses the
spawn-time snapshot variable secrets_store when calling
crate::secrets::scrub::scrub_with_store(&text, store), so exact-match redaction
can miss secrets updated while the worker ran; replace use of the captured
secrets_store with a runtime lookup of the current secrets store at completion
(e.g., fetch the up-to-date store from the shared/sealed secrets manager or lock
the Arc/Mutex that holds the live store) and then call scrub_with_store with
that live store before calling crate::secrets::scrub::scrub_leaks; apply the
same change where secrets_store is used at lines ~618-619 to ensure exact-match
redaction always uses the latest secrets rather than the spawn-time snapshot.

Ok(scrubbed)
}
Ok(Err(error)) => {
Expand All @@ -608,6 +623,7 @@ where
} else {
message
};
let scrubbed = crate::secrets::scrub::scrub_leaks(&scrubbed);
Err(WorkerCompletionError::Failed { message: scrubbed })
}
}
Expand Down
17 changes: 15 additions & 2 deletions src/agent/cortex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
};
Comment on lines +1263 to +1274

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Cortex task scrubbing uses a stale secrets snapshot.

secrets_snapshot is captured before the worker task starts. For long-running picked-up tasks, secret updates after spawn are invisible to Layer 1 redaction.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/agent/cortex.rs` around lines 1263 - 1274, Currently the spawned task
captures a one-time secrets_snapshot so Layer 1 redaction is stale; instead,
move a handle to the live secrets container into the spawned task (e.g. clone
deps.runtime_config.secrets and move that in), remove the pre-captured
secrets_snapshot, and change the scrub closure to fetch the current store on
each invocation (call secrets_handle.load().clone() and pass that to
crate::secrets::scrub::scrub_with_store) before calling scrub_leaks; keep using
the same scrub_with_store and scrub_leaks functions inside the closure and
ensure the tokio::spawn captures the secrets handle rather than a single
snapshot.


match worker.run().await {
Ok(result_text) => {
Ok(raw_result) => {
let result_text = scrub(raw_result);
let db_updated = task_store
.update(
&agent_id,
Expand Down Expand Up @@ -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())),
));
Comment on lines 1340 to 1342

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Failure logs still persist unsanitized error text.

You scrub the failure text for completion events, but logger.log still stores raw {error}/error.to_string(), which can reintroduce secret leakage in cortex_events.

🔧 Proposed fix
-                let (error_message, notify, success) = map_worker_completion_result(Err(
-                    WorkerCompletionError::failed(scrub(error.to_string())),
-                ));
+                let scrubbed_error = scrub(error.to_string());
+                let (error_message, notify, success) = map_worker_completion_result(Err(
+                    WorkerCompletionError::failed(scrubbed_error.clone()),
+                ));
@@
                 logger.log(
                     "task_pickup_failed",
-                    &format!("Picked-up task #{} failed: {error}", task.task_number),
+                    &format!("Picked-up task #{} failed: {scrubbed_error}", task.task_number),
                     Some(serde_json::json!({
                         "task_number": task.task_number,
                         "worker_id": worker_id.to_string(),
-                        "error": error.to_string(),
+                        "error": scrubbed_error,
                     })),
                 );

Also applies to: 1371-1378

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/agent/cortex.rs` around lines 1340 - 1342, The cortex event logging still
writes raw error.to_string() into logger.log/cortex_events even though
map_worker_completion_result wraps the error with
WorkerCompletionError::failed(scrub(...)); update the logging calls that use
error or error.to_string() (the occurrence around map_worker_completion_result
and the similar block at 1371-1378) to log the scrubbed message instead—either
extract the scrubbed string from scrub(error.to_string()) before constructing
WorkerCompletionError or use the already-scrubbed error_message returned by
map_worker_completion_result when calling logger.log/cortex_events so no raw
error text is sent to logs.

run_logger.log_worker_completed(worker_id, &error_message, false);

Expand Down
76 changes: 61 additions & 15 deletions src/hooks/spacebot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing to double-check with the new Worker/Branch “continue” behavior: the model will now see raw secret values in tool output.

Even if workers/branches can’t reply, they can still egress via tools like browser (outbound HTTP) and potentially shell/exec. Might be worth adding an args-level leak guard for those egressy tools (e.g. skip browser calls when scan_for_leaks(args) hits), or at least clarifying that the threat model here is strictly “don’t leak to channel/user”.

/// 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(),
};
}
Comment on lines 260 to +299

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor nit on the new “continue” path: leak_prefix still logs the first few chars of the matched secret. Since this can fire in more places now (workers/branches), I’d avoid logging any secret fragment at all.

One option is to log only the leak kind (plaintext vs url/base64/hex-encoded) without the match contents:

Suggested change
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.
tracing::warn!(
process_id = %self.process_id,
tool_name = %tool_name,
leak_prefix = %&leak[..leak.len().min(8)],
"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(),
};
}
if let Some(leak) = self.scan_for_leaks(result) {
let leak_kind = leak
.split_once(':')
.map(|(kind, _)| kind)
.unwrap_or("plaintext");
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.
tracing::warn!(
process_id = %self.process_id,
tool_name = %tool_name,
leak_kind = %leak_kind,
"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_kind = %leak_kind,
"secret leak detected in tool output, terminating agent"
);
return HookAction::Terminate {
reason:
"Tool output contained a secret. Agent terminated to prevent exfiltration."
.into(),
};
}
}
}

}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

HookAction::Continue
Expand Down Expand Up @@ -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)
Expand All @@ -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);
Comment on lines +548 to +555

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Worker/Branch ToolCompleted payload scrubbing is missing Layer 1 store redaction.

This path applies scrub_leaks() only. Secrets known to the store but not matching regex can still reach SSE/dashboard via ProcessEvent::ToolCompleted.

Please apply store-based scrubbing first (scrub_with_store) and then scrub_leaks, using a live/current secrets store.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/spacebot.rs` around lines 548 - 555, The ToolCompleted branch
currently only calls scrub_leaks on result and misses store-based redaction;
fetch the live/current secrets store (using your existing secrets store
accessor, e.g. crate::secrets::store::current() or equivalent), call
crate::secrets::scrub::scrub_with_store(result, &store) first, then pass that
output into crate::secrets::scrub::scrub_leaks, then truncate with
crate::tools::truncate_output(..., crate::tools::MAX_TOOL_OUTPUT_BYTES) and
finally call self.emit_tool_completed_event_from_capped(tool_name, capped);
ensure you still only do this for matches!(self.process_type,
ProcessType::Worker | ProcessType::Branch).

} else {
self.emit_tool_completed_event(tool_name, result);
}

tracing::debug!(
process_id = %self.process_id,
Expand Down
96 changes: 96 additions & 0 deletions src/secrets/scrub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ static LEAK_PATTERNS: LazyLock<Vec<Regex>> = 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<Regex> = 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<Regex> =
LazyLock::new(|| Regex::new(r"[A-Za-z0-9+/]{24,}={0,2}").expect("hardcoded regex"));

Expand Down Expand Up @@ -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
}
Comment on lines +241 to +255

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scrub_leaks() currently uses LEAK_PATTERNS, which includes the private-key header regex. That pattern only matches the header line, so this will redact -----BEGIN ... PRIVATE KEY----- but leave the base64 body untouched.

Given this PR is relying on egress scrubbing more heavily, I think it’s worth adding a dedicated multi-line PEM block scrub so we don’t accidentally “hide the header” but still leak the key material.

Suggested change
/// 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 {
let mut result = content.to_string();
for pattern in LEAK_PATTERNS.iter() {
result = pattern
.replace_all(&result, "[LEAKED_SECRET_REDACTED]")
.into_owned();
}
result
}
pub fn scrub_leaks(content: &str) -> String {
static PEM_BLOCK: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?s)-----BEGIN [^-]+-----.*?-----END [^-]+-----").expect("hardcoded regex")
});
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
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -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}"
);
}
}
3 changes: 3 additions & 0 deletions src/tools/set_status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down