-
Notifications
You must be signed in to change notification settings - Fork 362
fix: let workers/branches continue when secrets detected in tool output #323
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -249,6 +249,7 @@ async fn spawn_branch( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let event_tx = state.deps.event_tx.clone(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let agent_id = state.deps.agent_id.clone(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let channel_id = state.channel_id.clone(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let secrets_snapshot = state.deps.runtime_config.secrets.load().clone(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let branch_span = tracing::info_span!( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "branch.run", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -260,11 +261,22 @@ async fn spawn_branch( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async move { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if let Err(error) = branch.run(&prompt).await { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tracing::error!(branch_id = %branch_id, %error, "branch failed"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Scrub the failure message in case the error contains secrets | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // (e.g. from failed tool calls echoing back prompt content). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Layer 1: exact-match redaction of known secrets from the store. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Layer 2: regex-based redaction of unknown secret patterns. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let raw = format!("Branch failed: {error}"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let conclusion = if let Some(store) = secrets_snapshot.as_ref() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| crate::secrets::scrub::scrub_with_store(&raw, store) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| raw | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let conclusion = crate::secrets::scrub::scrub_leaks(&conclusion); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let _ = event_tx.send(crate::ProcessEvent::BranchResult { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| agent_id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| branch_id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| channel_id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| conclusion: format!("Branch failed: {error}"), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| conclusion, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
261
to
281
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice catch scrubbing the branch failure path. One thing: this only applies
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Worker completion redaction still uses a spawn-time store snapshot. Layer 1 here depends on Also applies to: 618-619 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Ok(scrubbed) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Ok(Err(error)) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -608,6 +623,7 @@ where | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| message | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let scrubbed = crate::secrets::scrub::scrub_leaks(&scrubbed); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Err(WorkerCompletionError::Failed { message: scrubbed }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cortex task scrubbing uses a stale secrets snapshot.
🤖 Prompt for AI Agents |
||
|
|
||
| match worker.run().await { | ||
| Ok(result_text) => { | ||
| Ok(raw_result) => { | ||
| let result_text = scrub(raw_result); | ||
| let db_updated = task_store | ||
| .update( | ||
| &agent_id, | ||
|
|
@@ -1325,7 +1338,7 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho | |
| } | ||
| Err(error) => { | ||
| let (error_message, notify, success) = map_worker_completion_result(Err( | ||
| WorkerCompletionError::failed(error.to_string()), | ||
| WorkerCompletionError::failed(scrub(error.to_string())), | ||
| )); | ||
|
Comment on lines
1340
to
1342
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Failure logs still persist unsanitized error text. You scrub the failure text for completion events, but 🔧 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 |
||
| run_logger.log_worker_completed(worker_id, &error_message, false); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor nit on the new “continue” path: One option is to log only the leak kind (plaintext vs url/base64/hex-encoded) without the match contents:
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| HookAction::Continue | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -488,9 +524,10 @@ where | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return guard_action; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Only enforce hard-stop leak blocking on channel egress (`reply`). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Worker and branch tool outputs are internal and should not terminate | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // long-running jobs. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Belt-and-suspenders check specifically for `reply` tool results on | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // channels. `guard_tool_result` already terminates channels on any tool | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // leak, but this catches any edge case where the reply content itself | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // has a different leak than the raw tool output. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if self.process_type == ProcessType::Channel | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| && tool_name == "reply" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| && let Some(leak) = self.scan_for_leaks(result) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -508,8 +545,17 @@ where | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Cap the result stored in the broadcast event to avoid blowing up | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // event subscribers with multi-MB tool results. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.emit_tool_completed_event(tool_name, result); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // event subscribers with multi-MB tool results. For worker/branch | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // processes, scrub leak patterns from the event payload so secrets | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // don't reach the SSE dashboard. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if matches!(self.process_type, ProcessType::Worker | ProcessType::Branch) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let scrubbed = crate::secrets::scrub::scrub_leaks(result); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let capped = | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| crate::tools::truncate_output(&scrubbed, crate::tools::MAX_TOOL_OUTPUT_BYTES); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.emit_tool_completed_event_from_capped(tool_name, capped); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+548
to
+555
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Worker/Branch This path applies Please apply store-based scrubbing first ( 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.emit_tool_completed_event(tool_name, result); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tracing::debug!( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| process_id = %self.process_id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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")); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #[cfg(test)] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| mod tests { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| use super::*; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -284,4 +316,68 @@ mod tests { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let result = scan_for_leaks("this is normal output with no secrets"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert!(result.is_none()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #[test] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fn scrub_leaks_redacts_anthropic_key() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let input = "found key sk-ant-abc123456789012345678 in config"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let result = scrub_leaks(input); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert!( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| !result.contains("sk-ant-abc123456789012345678"), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "secret should be redacted in: {result}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert!( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| result.contains("[LEAKED_SECRET_REDACTED]"), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "redaction marker missing in: {result}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert!( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| result.contains("found key"), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "surrounding text should be preserved in: {result}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #[test] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fn scrub_leaks_redacts_multiple_secrets() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let input = | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "keys: sk-ant-abc123456789012345678 and ghp_abcdefghijklmnopqrstuvwxyz0123456789"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let result = scrub_leaks(input); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert!( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| !result.contains("sk-ant-"), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "first secret should be redacted in: {result}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert!( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| !result.contains("ghp_"), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "second secret should be redacted in: {result}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #[test] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fn scrub_leaks_passes_through_clean_text() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let input = "this is normal output with no secrets"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert_eq!(scrub_leaks(input), input); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #[test] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fn scrub_leaks_redacts_full_pem_block() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let input = "config:\n-----BEGIN RSA PRIVATE KEY-----\n\ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| MIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8PbnGy0AHB7MhgHcTz6sE2I2yPB\n\ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| aFDrBz9vFqU4zK3L3hUfVnEy\n\ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -----END RSA PRIVATE KEY-----\nmore text"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let result = scrub_leaks(input); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert!( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| !result.contains("MIIEpAIBAAK"), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "PEM body should be redacted in: {result}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert!( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| !result.contains("BEGIN RSA PRIVATE KEY"), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "PEM header should be redacted in: {result}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert!( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| result.contains("[LEAKED_SECRET_REDACTED]"), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "redaction marker missing in: {result}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assert!( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| result.contains("more text"), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "surrounding text should be preserved in: {result}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.