1pub mod config;
8pub mod default;
9mod event_sanitizer;
10
11pub use config::{RedactionStrategy, SecurityConfig, SensitivityLevel};
12pub use default::{DefaultSecurityConfig, DefaultSecurityProvider, SensitivePattern};
13pub(crate) use event_sanitizer::AgentEventStreamSanitizer;
14
15use crate::hooks::HookEngine;
16
17pub fn sanitize_agent_event(
24 provider: &dyn SecurityProvider,
25 event: &crate::agent::AgentEvent,
26) -> crate::agent::AgentEvent {
27 use crate::agent::AgentEvent;
28
29 fn text(provider: &dyn SecurityProvider, value: &str) -> String {
30 provider.sanitize_output(value)
31 }
32
33 fn optional_text(provider: &dyn SecurityProvider, value: &Option<String>) -> Option<String> {
34 value.as_deref().map(|value| text(provider, value))
35 }
36
37 fn strings(provider: &dyn SecurityProvider, values: &[String]) -> Vec<String> {
38 values.iter().map(|value| text(provider, value)).collect()
39 }
40
41 fn json(provider: &dyn SecurityProvider, value: &serde_json::Value) -> serde_json::Value {
42 match value {
43 serde_json::Value::String(value) => serde_json::Value::String(text(provider, value)),
44 serde_json::Value::Array(values) => {
45 serde_json::Value::Array(values.iter().map(|value| json(provider, value)).collect())
46 }
47 serde_json::Value::Object(values) => serde_json::Value::Object(
48 values
49 .iter()
50 .map(|(key, value)| (key.clone(), json(provider, value)))
51 .collect(),
52 ),
53 value => value.clone(),
54 }
55 }
56
57 fn optional_json(
58 provider: &dyn SecurityProvider,
59 value: &Option<serde_json::Value>,
60 ) -> Option<serde_json::Value> {
61 value.as_ref().map(|value| json(provider, value))
62 }
63
64 fn tool_error_kind(
65 provider: &dyn SecurityProvider,
66 value: &Option<crate::tools::ToolErrorKind>,
67 ) -> Option<crate::tools::ToolErrorKind> {
68 use crate::tools::ToolErrorKind;
69
70 value.as_ref().map(|kind| match kind {
71 ToolErrorKind::VersionConflict {
72 path,
73 expected,
74 actual,
75 } => ToolErrorKind::VersionConflict {
76 path: text(provider, path),
77 expected: text(provider, expected),
78 actual: optional_text(provider, actual),
79 },
80 ToolErrorKind::RemoteGitConflict { code, message } => {
81 ToolErrorKind::RemoteGitConflict {
82 code: text(provider, code),
83 message: text(provider, message),
84 }
85 }
86 ToolErrorKind::NotFound { path } => ToolErrorKind::NotFound {
87 path: text(provider, path),
88 },
89 ToolErrorKind::InvalidArgument { message } => ToolErrorKind::InvalidArgument {
90 message: text(provider, message),
91 },
92 ToolErrorKind::Unsupported { message } => ToolErrorKind::Unsupported {
93 message: text(provider, message),
94 },
95 ToolErrorKind::Timeout { op, duration_ms } => ToolErrorKind::Timeout {
96 op: text(provider, op),
97 duration_ms: *duration_ms,
98 },
99 ToolErrorKind::Cancelled { op } => ToolErrorKind::Cancelled {
100 op: text(provider, op),
101 },
102 ToolErrorKind::PartialFailure { failed, total } => ToolErrorKind::PartialFailure {
103 failed: *failed,
104 total: *total,
105 },
106 ToolErrorKind::RateLimited { retry_after_ms } => ToolErrorKind::RateLimited {
107 retry_after_ms: *retry_after_ms,
108 },
109 })
110 }
111
112 fn task(
113 provider: &dyn SecurityProvider,
114 task: &crate::planning::Task,
115 ) -> crate::planning::Task {
116 let mut task = task.clone();
117 task.content = text(provider, &task.content);
118 task.success_criteria = optional_text(provider, &task.success_criteria);
119 task
120 }
121
122 fn plan(
123 provider: &dyn SecurityProvider,
124 plan: &crate::planning::ExecutionPlan,
125 ) -> crate::planning::ExecutionPlan {
126 let mut plan = plan.clone();
127 plan.goal = text(provider, &plan.goal);
128 plan.steps = plan.steps.iter().map(|item| task(provider, item)).collect();
129 plan
130 }
131
132 fn goal(
133 provider: &dyn SecurityProvider,
134 goal: &crate::planning::AgentGoal,
135 ) -> crate::planning::AgentGoal {
136 let mut goal = goal.clone();
137 goal.description = text(provider, &goal.description);
138 goal.success_criteria = strings(provider, &goal.success_criteria);
139 goal
140 }
141
142 fn verification_summary(
143 provider: &dyn SecurityProvider,
144 summary: &crate::verification::VerificationSummary,
145 ) -> crate::verification::VerificationSummary {
146 let mut summary = summary.clone();
147 summary.pending_subjects = strings(provider, &summary.pending_subjects);
148 summary.failed_subjects = strings(provider, &summary.failed_subjects);
149 summary
150 }
151
152 fn response_meta(
153 provider: &dyn SecurityProvider,
154 meta: &Option<crate::llm::LlmResponseMeta>,
155 ) -> Option<crate::llm::LlmResponseMeta> {
156 meta.as_ref().map(|meta| {
157 let mut meta = meta.clone();
158 meta.request_url = optional_text(provider, &meta.request_url);
159 meta
160 })
161 }
162
163 match event {
164 AgentEvent::Start { prompt } => AgentEvent::Start {
165 prompt: text(provider, prompt),
166 },
167 AgentEvent::AgentModeChanged {
168 mode,
169 agent,
170 description,
171 } => AgentEvent::AgentModeChanged {
172 mode: mode.clone(),
173 agent: agent.clone(),
174 description: text(provider, description),
175 },
176 AgentEvent::TextDelta { text: value } => AgentEvent::TextDelta {
177 text: text(provider, value),
178 },
179 AgentEvent::ReasoningDelta { text: value } => AgentEvent::ReasoningDelta {
180 text: text(provider, value),
181 },
182 AgentEvent::ToolInputDelta { id, delta } => AgentEvent::ToolInputDelta {
183 id: id.clone(),
184 delta: text(provider, delta),
185 },
186 AgentEvent::ToolExecutionStart { id, name, args } => AgentEvent::ToolExecutionStart {
187 id: id.clone(),
188 name: name.clone(),
189 args: json(provider, args),
190 },
191 AgentEvent::ToolEnd {
192 id,
193 name,
194 args,
195 output,
196 exit_code,
197 metadata,
198 error_kind,
199 } => AgentEvent::ToolEnd {
200 id: id.clone(),
201 name: name.clone(),
202 args: optional_json(provider, args),
203 output: text(provider, output),
204 exit_code: *exit_code,
205 metadata: optional_json(provider, metadata),
206 error_kind: tool_error_kind(provider, error_kind),
207 },
208 AgentEvent::ToolOutputDelta { id, name, delta } => AgentEvent::ToolOutputDelta {
209 id: id.clone(),
210 name: name.clone(),
211 delta: text(provider, delta),
212 },
213 AgentEvent::End {
214 text: value,
215 usage,
216 verification_summary: summary,
217 meta,
218 } => AgentEvent::End {
219 text: text(provider, value),
220 usage: usage.clone(),
221 verification_summary: Box::new(verification_summary(provider, summary)),
222 meta: response_meta(provider, meta),
223 },
224 AgentEvent::Error { message } => AgentEvent::Error {
225 message: text(provider, message),
226 },
227 AgentEvent::ConfirmationRequired {
228 tool_id,
229 tool_name,
230 args,
231 timeout_ms,
232 } => AgentEvent::ConfirmationRequired {
233 tool_id: tool_id.clone(),
234 tool_name: tool_name.clone(),
235 args: json(provider, args),
236 timeout_ms: *timeout_ms,
237 },
238 AgentEvent::ConfirmationReceived {
239 tool_id,
240 approved,
241 reason,
242 } => AgentEvent::ConfirmationReceived {
243 tool_id: tool_id.clone(),
244 approved: *approved,
245 reason: optional_text(provider, reason),
246 },
247 AgentEvent::ConfirmationTimeout {
248 tool_id,
249 action_taken,
250 } => AgentEvent::ConfirmationTimeout {
251 tool_id: tool_id.clone(),
252 action_taken: action_taken.clone(),
253 },
254 AgentEvent::ExternalTaskPending {
255 task_id,
256 session_id,
257 lane,
258 command_type,
259 payload,
260 timeout_ms,
261 } => AgentEvent::ExternalTaskPending {
262 task_id: task_id.clone(),
263 session_id: session_id.clone(),
264 lane: *lane,
265 command_type: command_type.clone(),
266 payload: json(provider, payload),
267 timeout_ms: *timeout_ms,
268 },
269 AgentEvent::PermissionDenied {
270 tool_id,
271 tool_name,
272 args,
273 reason,
274 } => AgentEvent::PermissionDenied {
275 tool_id: tool_id.clone(),
276 tool_name: tool_name.clone(),
277 args: json(provider, args),
278 reason: text(provider, reason),
279 },
280 AgentEvent::CommandDeadLettered {
281 command_id,
282 command_type,
283 lane,
284 error,
285 attempts,
286 } => AgentEvent::CommandDeadLettered {
287 command_id: command_id.clone(),
288 command_type: command_type.clone(),
289 lane: lane.clone(),
290 error: text(provider, error),
291 attempts: *attempts,
292 },
293 AgentEvent::QueueAlert {
294 level,
295 alert_type,
296 message,
297 } => AgentEvent::QueueAlert {
298 level: level.clone(),
299 alert_type: alert_type.clone(),
300 message: text(provider, message),
301 },
302 AgentEvent::TaskUpdated { session_id, tasks } => AgentEvent::TaskUpdated {
303 session_id: session_id.clone(),
304 tasks: tasks.iter().map(|item| task(provider, item)).collect(),
305 },
306 AgentEvent::MemoryStored {
307 memory_id,
308 memory_type,
309 importance,
310 tags,
311 } => AgentEvent::MemoryStored {
312 memory_id: memory_id.clone(),
313 memory_type: memory_type.clone(),
314 importance: *importance,
315 tags: strings(provider, tags),
316 },
317 AgentEvent::MemoryRecalled {
318 memory_id,
319 content,
320 relevance,
321 } => AgentEvent::MemoryRecalled {
322 memory_id: memory_id.clone(),
323 content: text(provider, content),
324 relevance: *relevance,
325 },
326 AgentEvent::MemoriesSearched {
327 query,
328 tags,
329 result_count,
330 } => AgentEvent::MemoriesSearched {
331 query: optional_text(provider, query),
332 tags: strings(provider, tags),
333 result_count: *result_count,
334 },
335 AgentEvent::SubagentStart {
336 task_id,
337 session_id,
338 parent_session_id,
339 agent,
340 description,
341 started_ms,
342 } => AgentEvent::SubagentStart {
343 task_id: task_id.clone(),
344 session_id: session_id.clone(),
345 parent_session_id: parent_session_id.clone(),
346 agent: agent.clone(),
347 description: text(provider, description),
348 started_ms: *started_ms,
349 },
350 AgentEvent::SubagentProgress {
351 task_id,
352 session_id,
353 status,
354 metadata,
355 } => AgentEvent::SubagentProgress {
356 task_id: task_id.clone(),
357 session_id: session_id.clone(),
358 status: text(provider, status),
359 metadata: json(provider, metadata),
360 },
361 AgentEvent::SubagentEnd {
362 task_id,
363 session_id,
364 agent,
365 output,
366 success,
367 finished_ms,
368 } => AgentEvent::SubagentEnd {
369 task_id: task_id.clone(),
370 session_id: session_id.clone(),
371 agent: agent.clone(),
372 output: text(provider, output),
373 success: *success,
374 finished_ms: *finished_ms,
375 },
376 AgentEvent::PlanningStart { prompt } => AgentEvent::PlanningStart {
377 prompt: text(provider, prompt),
378 },
379 AgentEvent::PlanningEnd {
380 plan: value,
381 estimated_steps,
382 } => AgentEvent::PlanningEnd {
383 plan: plan(provider, value),
384 estimated_steps: *estimated_steps,
385 },
386 AgentEvent::StepStart {
387 step_id,
388 description,
389 step_number,
390 total_steps,
391 } => AgentEvent::StepStart {
392 step_id: step_id.clone(),
393 description: text(provider, description),
394 step_number: *step_number,
395 total_steps: *total_steps,
396 },
397 AgentEvent::GoalExtracted { goal: value } => AgentEvent::GoalExtracted {
398 goal: goal(provider, value),
399 },
400 AgentEvent::GoalProgress {
401 goal,
402 progress,
403 completed_steps,
404 total_steps,
405 } => AgentEvent::GoalProgress {
406 goal: text(provider, goal),
407 progress: *progress,
408 completed_steps: *completed_steps,
409 total_steps: *total_steps,
410 },
411 AgentEvent::GoalAchieved {
412 goal,
413 total_steps,
414 duration_ms,
415 } => AgentEvent::GoalAchieved {
416 goal: text(provider, goal),
417 total_steps: *total_steps,
418 duration_ms: *duration_ms,
419 },
420 AgentEvent::PersistenceFailed {
421 session_id,
422 operation,
423 error,
424 } => AgentEvent::PersistenceFailed {
425 session_id: session_id.clone(),
426 operation: operation.clone(),
427 error: text(provider, error),
428 },
429 AgentEvent::BudgetThresholdHit {
430 resource,
431 kind,
432 consumed,
433 limit,
434 message,
435 } => AgentEvent::BudgetThresholdHit {
436 resource: resource.clone(),
437 kind: kind.clone(),
438 consumed: *consumed,
439 limit: *limit,
440 message: optional_text(provider, message),
441 },
442 AgentEvent::PassivationRequested {
443 reason,
444 deadline_ms,
445 } => AgentEvent::PassivationRequested {
446 reason: text(provider, reason),
447 deadline_ms: *deadline_ms,
448 },
449 _ => event.clone(),
450 }
451}
452
453pub trait SecurityProvider: Send + Sync {
458 fn taint_input(&self, _text: &str) {}
460
461 fn sanitize_output(&self, text: &str) -> String {
464 text.to_string()
465 }
466
467 fn wipe(&self) {}
469
470 fn register_hooks(&self, _hook_engine: &HookEngine) {}
472
473 fn teardown(&self, _hook_engine: &HookEngine) {}
475}
476
477pub struct NoOpSecurityProvider;
479
480impl SecurityProvider for NoOpSecurityProvider {}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485
486 #[test]
487 fn test_noop_provider_passthrough() {
488 let provider = NoOpSecurityProvider;
489 provider.taint_input("SSN: 123-45-6789");
490 let output = provider.sanitize_output("SSN: 123-45-6789");
491 assert_eq!(output, "SSN: 123-45-6789");
492 }
493
494 #[test]
495 fn test_noop_provider_wipe() {
496 let provider = NoOpSecurityProvider;
497 provider.wipe(); }
499
500 #[test]
501 fn test_noop_provider_hooks() {
502 let engine = HookEngine::new();
503 let provider = NoOpSecurityProvider;
504 provider.register_hooks(&engine);
505 provider.teardown(&engine);
506 assert_eq!(engine.hook_count(), 0);
507 }
508
509 #[test]
510 fn agent_event_sanitization_redacts_streams_arguments_and_outputs() {
511 let provider = DefaultSecurityProvider::new();
512 let secret = "user@example.com";
513 let events = [
514 crate::agent::AgentEvent::TextDelta {
515 text: secret.to_string(),
516 },
517 crate::agent::AgentEvent::ReasoningDelta {
518 text: secret.to_string(),
519 },
520 crate::agent::AgentEvent::ToolInputDelta {
521 id: Some("tool-1".to_string()),
522 delta: format!(r#"{{"token":"{secret}"}}"#),
523 },
524 crate::agent::AgentEvent::ToolExecutionStart {
525 id: "tool-1".to_string(),
526 name: "bash".to_string(),
527 args: serde_json::json!({"command": format!("echo {secret}")}),
528 },
529 crate::agent::AgentEvent::ToolOutputDelta {
530 id: "tool-1".to_string(),
531 name: "bash".to_string(),
532 delta: secret.to_string(),
533 },
534 crate::agent::AgentEvent::ToolEnd {
535 id: "tool-1".to_string(),
536 name: "bash".to_string(),
537 args: Some(serde_json::json!({"command": format!("echo {secret}")})),
538 output: secret.to_string(),
539 exit_code: 0,
540 metadata: Some(serde_json::json!({"contact": secret})),
541 error_kind: None,
542 },
543 ];
544
545 for event in &events {
546 let sanitized = sanitize_agent_event(&provider, event);
547 let json = serde_json::to_string(&sanitized).unwrap();
548 assert!(!json.contains(secret), "unsanitized event: {json}");
549 assert!(json.contains("REDACTED:EMAIL"));
550 }
551
552 let sanitized = sanitize_agent_event(&provider, &events[3]);
553 assert!(matches!(
554 sanitized,
555 crate::agent::AgentEvent::ToolExecutionStart { id, name, .. }
556 if id == "tool-1" && name == "bash"
557 ));
558 }
559
560 #[test]
561 fn agent_event_sanitization_redacts_every_tool_error_kind_string() {
562 use crate::agent::AgentEvent;
563 use crate::tools::ToolErrorKind;
564
565 let provider = DefaultSecurityProvider::new();
566 let secret = "user@example.com";
567 let redacted = "[REDACTED:EMAIL]";
568 let cases = [
569 (
570 ToolErrorKind::VersionConflict {
571 path: format!("path/{secret}"),
572 expected: format!("expected: {secret}"),
573 actual: Some(format!("actual: {secret}")),
574 },
575 ToolErrorKind::VersionConflict {
576 path: format!("path/{redacted}"),
577 expected: format!("expected: {redacted}"),
578 actual: Some(format!("actual: {redacted}")),
579 },
580 ),
581 (
582 ToolErrorKind::RemoteGitConflict {
583 code: format!("code: {secret}"),
584 message: format!("message: {secret}"),
585 },
586 ToolErrorKind::RemoteGitConflict {
587 code: format!("code: {redacted}"),
588 message: format!("message: {redacted}"),
589 },
590 ),
591 (
592 ToolErrorKind::NotFound {
593 path: format!("path/{secret}"),
594 },
595 ToolErrorKind::NotFound {
596 path: format!("path/{redacted}"),
597 },
598 ),
599 (
600 ToolErrorKind::InvalidArgument {
601 message: format!("message: {secret}"),
602 },
603 ToolErrorKind::InvalidArgument {
604 message: format!("message: {redacted}"),
605 },
606 ),
607 (
608 ToolErrorKind::Unsupported {
609 message: format!("message: {secret}"),
610 },
611 ToolErrorKind::Unsupported {
612 message: format!("message: {redacted}"),
613 },
614 ),
615 (
616 ToolErrorKind::Timeout {
617 op: format!("operation: {secret}"),
618 duration_ms: 42,
619 },
620 ToolErrorKind::Timeout {
621 op: format!("operation: {redacted}"),
622 duration_ms: 42,
623 },
624 ),
625 ];
626
627 for (error_kind, expected) in cases {
628 let event = AgentEvent::ToolEnd {
629 id: "tool-1".to_string(),
630 name: "test".to_string(),
631 args: None,
632 output: String::new(),
633 exit_code: 1,
634 metadata: None,
635 error_kind: Some(error_kind),
636 };
637
638 let sanitized = sanitize_agent_event(&provider, &event);
639 let AgentEvent::ToolEnd {
640 id,
641 name,
642 exit_code,
643 error_kind,
644 ..
645 } = sanitized
646 else {
647 panic!("sanitization changed the event variant");
648 };
649
650 assert_eq!(id, "tool-1");
651 assert_eq!(name, "test");
652 assert_eq!(exit_code, 1);
653 assert_eq!(error_kind, Some(expected));
654 }
655 }
656}