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