Skip to content

Commit ba594ce

Browse files
committed
fix(claw-server): scrub the summary in the recorded name_session dispatch args
The name_session dispatch persisted its raw arguments, so a summary the agent should have kept PII-free was scrubbed in tasks.task_summary and the search index but still surfaced verbatim in the audit detail timeline via the dispatch argsJson. Substitute the already-scrubbed summary into the recorded arguments so every audit surface shows the sanitized copy.
1 parent a14559e commit ba594ce

1 file changed

Lines changed: 44 additions & 14 deletions

File tree

  • packages/browseros-agent/apps/claw-server-rust/src/api/mcp

packages/browseros-agent/apps/claw-server-rust/src/api/mcp/service.rs

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -164,24 +164,23 @@ impl ClawMcpService {
164164
);
165165
}
166166
}
167-
if let Some(summary) = raw_args
167+
// Scrub structural PII from any provided summary before it is persisted or
168+
// indexed for search; stored locally only, never sent to analytics. Last write wins.
169+
let scrubbed_summary = raw_args
168170
.get("summary")
169171
.and_then(Value::as_str)
170172
.map(str::trim)
171173
.filter(|summary| !summary.is_empty())
174+
.map(scrub_summary);
175+
if let Some(clean) = scrubbed_summary.as_deref()
176+
&& !clean.is_empty()
177+
&& let Err(error) = self
178+
.state
179+
.audit_log
180+
.set_task_summary(started.session.id().as_str(), clean)
181+
.await
172182
{
173-
// Scrub structural PII before it is persisted and indexed for audit search;
174-
// stored locally only, never sent to analytics. Last write wins.
175-
let clean = scrub_summary(summary);
176-
if !clean.is_empty()
177-
&& let Err(error) = self
178-
.state
179-
.audit_log
180-
.set_task_summary(started.session.id().as_str(), &clean)
181-
.await
182-
{
183-
warn!(error = %error, "failed to store task summary");
184-
}
183+
warn!(error = %error, "failed to store task summary");
185184
}
186185
let browser = self.state.browser.session().await;
187186
apply_agent_tab_group_title(
@@ -193,13 +192,19 @@ impl ClawMcpService {
193192
)
194193
.await;
195194
let result = ToolResult::text(rename.response, None);
195+
// The audit dispatch persists the raw tool arguments; substitute the scrubbed
196+
// summary so the unsanitized text never reaches the audit detail timeline.
197+
let dispatch_args = match scrubbed_summary.as_deref() {
198+
Some(clean) => with_scrubbed_summary(raw_args, clean),
199+
None => raw_args.clone(),
200+
};
196201
if let Err(error) = record_local_tool_dispatch(
197202
&self.state,
198203
LocalToolDispatch {
199204
session: &started.session,
200205
agent_label: &started.agent_label,
201206
tool_name: NAME_SESSION_TOOL_NAME,
202-
raw_args,
207+
raw_args: &dispatch_args,
203208
result: &result,
204209
duration_ms: i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX),
205210
dispatch_id: dispatch_id.clone(),
@@ -703,6 +708,17 @@ fn scrub_summary(raw: &str) -> String {
703708
}
704709
}
705710

711+
/// Clones the tool arguments with the `summary` field replaced by its already-scrubbed
712+
/// form, so the audit dispatch timeline persists the sanitized summary rather than the raw
713+
/// one the scrubber removed from `tasks.task_summary` and the search index.
714+
fn with_scrubbed_summary(raw_args: &Value, clean: &str) -> Value {
715+
let mut owned = raw_args.clone();
716+
if let Some(object) = owned.as_object_mut() {
717+
object.insert("summary".to_string(), Value::String(clean.to_string()));
718+
}
719+
owned
720+
}
721+
706722
fn is_pii_token(token: &str) -> bool {
707723
let lower = token.to_ascii_lowercase();
708724
if token.contains('@')
@@ -1298,6 +1314,20 @@ mod tests {
12981314
assert!(scrub_summary(&raw).chars().count() <= SUMMARY_MAX_LEN);
12991315
}
13001316

1317+
#[test]
1318+
fn with_scrubbed_summary_replaces_summary_and_keeps_other_args() {
1319+
let raw = "Emailed john@acme.com the invoices";
1320+
let clean = scrub_summary(raw);
1321+
let sanitized =
1322+
with_scrubbed_summary(&json!({ "name": "invoice sync", "summary": raw }), &clean);
1323+
// The recorded dispatch args carry the scrubbed copy, never the raw one.
1324+
assert_eq!(sanitized["summary"].as_str(), Some(clean.as_str()));
1325+
assert!(!clean.contains('@'));
1326+
assert!(!clean.to_ascii_lowercase().contains("acme.com"));
1327+
// Unrelated arguments are preserved verbatim.
1328+
assert_eq!(sanitized["name"].as_str(), Some("invoice sync"));
1329+
}
1330+
13011331
#[tokio::test]
13021332
async fn save_skill_is_registered_locally_with_annotations() -> anyhow::Result<()> {
13031333
let call = crate::api::mcp::test_support::tool_call("tabs", json!({})).await?;

0 commit comments

Comments
 (0)