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