Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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