1use std::collections::{HashMap, VecDeque};
39use std::sync::Arc;
40use std::sync::atomic::{AtomicU64, Ordering};
41use std::time::Duration;
42
43use tokio::sync::mpsc;
44use zeph_sanitizer::pii::PiiFilter;
45use zeph_sanitizer::secret_mask::SecretMaskRegistry;
46use zeph_sanitizer::secret_shape::scrub_secret_shapes;
47use zeph_sanitizer::{ContentSanitizer, ContentSource, ContentSourceKind};
48
49use crate::state::SubAgentState;
50
51const FORWARD_CHANNEL_CAPACITY: usize = 128;
54
55const FORWARD_RING_CAPACITY: usize = 200;
57
58const FORWARD_BUFFER_GRACE: Duration = Duration::from_secs(5);
61
62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
69pub struct ForwardSurfaces {
70 pub tui: bool,
72 pub bare: bool,
74}
75
76impl ForwardSurfaces {
77 #[must_use]
79 pub fn any(self) -> bool {
80 self.tui || self.bare
81 }
82}
83
84#[derive(Debug, Clone)]
88pub(crate) struct RawChunk {
89 kind: ForwardChunkKind,
90}
91
92#[derive(Debug, Clone)]
95#[non_exhaustive]
96pub(crate) enum ForwardChunkKind {
97 Text(String),
99 Thinking(String),
101 Terminal(SubAgentState),
104}
105
106#[derive(Debug, Clone)]
115pub(crate) struct SanitizedChunk {
116 pub(crate) task_id: Arc<str>,
118 pub(crate) def_name: Arc<str>,
120 pub(crate) seq: u64,
122 pub(crate) kind: SanitizedChunkKind,
124}
125
126#[derive(Debug, Clone)]
128#[non_exhaustive]
129pub(crate) enum SanitizedChunkKind {
130 Text(String),
132 Thinking(String),
134 Terminal(SubAgentState),
136}
137
138pub(crate) struct SanitizeLayers {
152 pub(crate) sanitizer: ContentSanitizer,
153 pub(crate) secret_registry: Option<Arc<SecretMaskRegistry>>,
154 pub(crate) pii_filter: Option<PiiFilter>,
155}
156
157fn sanitize_text(raw_text: &str, def_name: &str, layers: &SanitizeLayers) -> String {
158 let source = ContentSource::new(ContentSourceKind::ToolResult).with_identifier(def_name);
159 let mut body = layers.sanitizer.sanitize(raw_text, source).body;
160 if let Some(registry) = &layers.secret_registry {
164 body = registry.mask(&body);
165 }
166 body = scrub_secret_shapes(&body).into_owned();
167 if let Some(filter) = &layers.pii_filter {
168 body = filter.scrub(&body).into_owned();
169 }
170 body
171}
172
173const SANITIZE_HOLDBACK_BYTES: usize = 256;
191
192#[derive(Default)]
197struct PendingSanitizeBuffers {
198 text: String,
199 thinking: String,
200}
201
202fn split_off_safe_prefix(buf: &mut String, holdback: usize) -> Option<String> {
209 if buf.is_empty() {
210 return None;
211 }
212 let target = buf.len().saturating_sub(holdback);
213 let boundary = buf.floor_char_boundary(target);
214 if boundary == 0 {
215 return None;
216 }
217 let prefix = buf[..boundary].to_owned();
218 buf.drain(..boundary);
219 Some(prefix)
220}
221
222fn try_flush_kind(
227 buf: &mut String,
228 holdback: usize,
229 def_name: &str,
230 layers: &SanitizeLayers,
231 wrap_kind: fn(String) -> SanitizedChunkKind,
232) -> Option<SanitizedChunkKind> {
233 let safe = split_off_safe_prefix(buf, holdback)?;
234 Some(wrap_kind(sanitize_text(&safe, def_name, layers)))
235}
236
237fn make_sanitized_chunk(
238 task_id: &Arc<str>,
239 def_name: &Arc<str>,
240 seq: u64,
241 kind: SanitizedChunkKind,
242) -> SanitizedChunk {
243 SanitizedChunk {
244 task_id: Arc::clone(task_id),
245 def_name: Arc::clone(def_name),
246 seq,
247 kind,
248 }
249}
250
251#[allow(clippy::too_many_arguments)]
256fn flush_all_pending(
257 pending: &mut PendingSanitizeBuffers,
258 task_id: &Arc<str>,
259 def_name: &Arc<str>,
260 layers: &SanitizeLayers,
261 surfaces: ForwardSurfaces,
262 buffer: &ForwardBuffer,
263 dispatch: &mut impl FnMut(&SanitizedChunk, ForwardSurfaces, &ForwardBuffer),
264 emit_seq: &mut u64,
265) {
266 if let Some(kind) = try_flush_kind(
267 &mut pending.text,
268 0,
269 def_name.as_ref(),
270 layers,
271 SanitizedChunkKind::Text,
272 ) {
273 dispatch(
274 &make_sanitized_chunk(task_id, def_name, *emit_seq, kind),
275 surfaces,
276 buffer,
277 );
278 *emit_seq += 1;
279 }
280 if let Some(kind) = try_flush_kind(
281 &mut pending.thinking,
282 0,
283 def_name.as_ref(),
284 layers,
285 SanitizedChunkKind::Thinking,
286 ) {
287 dispatch(
288 &make_sanitized_chunk(task_id, def_name, *emit_seq, kind),
289 surfaces,
290 buffer,
291 );
292 *emit_seq += 1;
293 }
294}
295
296pub(crate) struct ForwardSender {
305 tx: mpsc::Sender<RawChunk>,
306 task_id: Arc<str>,
307 def_name: Arc<str>,
308 seq: AtomicU64,
309 dropped: AtomicU64,
310}
311
312impl ForwardSender {
313 pub(crate) fn new(tx: mpsc::Sender<RawChunk>, task_id: Arc<str>, def_name: Arc<str>) -> Self {
314 Self {
315 tx,
316 task_id,
317 def_name,
318 seq: AtomicU64::new(0),
319 dropped: AtomicU64::new(0),
320 }
321 }
322
323 fn try_send(&self, kind: ForwardChunkKind) {
324 let seq = self.seq.fetch_add(1, Ordering::Relaxed);
325 let chunk = RawChunk { kind };
326 if self.tx.try_send(chunk).is_ok() {
327 tracing::debug!(
328 task_id = %self.task_id,
329 def_name = %self.def_name,
330 seq,
331 "subagent.forward.emit"
332 );
333 } else {
334 let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1;
335 tracing::warn!(
336 task_id = %self.task_id,
337 def_name = %self.def_name,
338 seq,
339 dropped,
340 "subagent.forward.drop: ingress channel full, chunk dropped"
341 );
342 }
343 }
344
345 pub(crate) fn send_text(&self, text: &str) {
357 if text.is_empty() {
358 return;
359 }
360 self.try_send(ForwardChunkKind::Text(text.to_owned()));
361 }
362
363 pub(crate) fn send_thinking(&self, text: &str) {
367 if text.is_empty() {
368 return;
369 }
370 self.try_send(ForwardChunkKind::Thinking(text.to_owned()));
371 }
372
373 pub(crate) fn send_terminal(&self, state: SubAgentState) {
376 tracing::debug!(task_id = %self.task_id, ?state, "subagent.forward.terminal");
377 self.try_send(ForwardChunkKind::Terminal(state));
378 }
379}
380
381pub(crate) type ForwardBuffer = std::sync::Mutex<HashMap<String, VecDeque<String>>>;
382
383fn display_line(kind: &SanitizedChunkKind) -> Option<String> {
386 match kind {
387 SanitizedChunkKind::Text(t) => Some(t.clone()),
388 SanitizedChunkKind::Thinking(t) => Some(format!("[thinking] {t}")),
389 SanitizedChunkKind::Terminal(_) => None,
390 }
391}
392
393fn state_str(state: SubAgentState) -> &'static str {
394 match state {
395 SubAgentState::Submitted => "submitted",
396 SubAgentState::Working => "working",
397 SubAgentState::Completed => "completed",
398 SubAgentState::Failed => "failed",
399 SubAgentState::Canceled => "canceled",
400 }
401}
402
403fn emit_bare_line(chunk: &SanitizedChunk) {
407 #[derive(serde::Serialize)]
408 struct BareForwardEvent<'a> {
409 task_id: &'a str,
410 def_name: &'a str,
411 seq: u64,
412 kind: &'static str,
413 #[serde(skip_serializing_if = "Option::is_none")]
414 content: Option<&'a str>,
415 #[serde(skip_serializing_if = "Option::is_none")]
416 state: Option<&'static str>,
417 }
418
419 let (kind, content, state) = match &chunk.kind {
420 SanitizedChunkKind::Text(t) => ("text", Some(t.as_str()), None),
421 SanitizedChunkKind::Thinking(t) => ("thinking", Some(t.as_str()), None),
422 SanitizedChunkKind::Terminal(s) => ("terminal", None, Some(state_str(*s))),
423 };
424 let event = BareForwardEvent {
425 task_id: &chunk.task_id,
426 def_name: &chunk.def_name,
427 seq: chunk.seq,
428 kind,
429 content,
430 state,
431 };
432 if let Ok(line) = serde_json::to_string(&event) {
433 println!("{line}");
434 }
435}
436
437fn dispatch_chunk(chunk: &SanitizedChunk, surfaces: ForwardSurfaces, buffer: &ForwardBuffer) {
439 if surfaces.tui
440 && let Some(line) = display_line(&chunk.kind)
441 {
442 let mut guard = buffer
443 .lock()
444 .unwrap_or_else(std::sync::PoisonError::into_inner);
445 let ring = guard.entry(chunk.task_id.to_string()).or_default();
446 ring.push_back(line);
447 while ring.len() > FORWARD_RING_CAPACITY {
448 ring.pop_front();
449 }
450 }
451 if surfaces.bare {
452 emit_bare_line(chunk);
453 }
454}
455
456pub(crate) fn new_channel(
458 task_id: Arc<str>,
459 def_name: Arc<str>,
460) -> (ForwardSender, mpsc::Receiver<RawChunk>) {
461 let (tx, rx) = mpsc::channel(FORWARD_CHANNEL_CAPACITY);
462 (ForwardSender::new(tx, task_id, def_name), rx)
463}
464
465pub(crate) async fn run_forward_drain(
486 task_id: Arc<str>,
487 def_name: Arc<str>,
488 rx: mpsc::Receiver<RawChunk>,
489 layers: SanitizeLayers,
490 surfaces: ForwardSurfaces,
491 buffer: Arc<ForwardBuffer>,
492) {
493 run_forward_drain_with(
494 task_id,
495 def_name,
496 rx,
497 layers,
498 surfaces,
499 buffer,
500 dispatch_chunk,
501 )
502 .await;
503}
504
505async fn run_forward_drain_with(
513 task_id: Arc<str>,
514 def_name: Arc<str>,
515 mut rx: mpsc::Receiver<RawChunk>,
516 layers: SanitizeLayers,
517 surfaces: ForwardSurfaces,
518 buffer: Arc<ForwardBuffer>,
519 mut dispatch: impl FnMut(&SanitizedChunk, ForwardSurfaces, &ForwardBuffer),
520) {
521 let mut pending = PendingSanitizeBuffers::default();
522 let mut emit_seq: u64 = 0;
523
524 loop {
525 if let Some(raw) = rx.recv().await {
526 match raw.kind {
527 ForwardChunkKind::Text(delta) => {
528 pending.text.push_str(&delta);
529 if let Some(kind) = try_flush_kind(
530 &mut pending.text,
531 SANITIZE_HOLDBACK_BYTES,
532 def_name.as_ref(),
533 &layers,
534 SanitizedChunkKind::Text,
535 ) {
536 dispatch(
537 &make_sanitized_chunk(&task_id, &def_name, emit_seq, kind),
538 surfaces,
539 &buffer,
540 );
541 emit_seq += 1;
542 }
543 }
544 ForwardChunkKind::Thinking(delta) => {
545 pending.thinking.push_str(&delta);
546 if let Some(kind) = try_flush_kind(
547 &mut pending.thinking,
548 SANITIZE_HOLDBACK_BYTES,
549 def_name.as_ref(),
550 &layers,
551 SanitizedChunkKind::Thinking,
552 ) {
553 dispatch(
554 &make_sanitized_chunk(&task_id, &def_name, emit_seq, kind),
555 surfaces,
556 &buffer,
557 );
558 emit_seq += 1;
559 }
560 }
561 ForwardChunkKind::Terminal(state) => {
562 flush_all_pending(
563 &mut pending,
564 &task_id,
565 &def_name,
566 &layers,
567 surfaces,
568 &buffer,
569 &mut dispatch,
570 &mut emit_seq,
571 );
572 let chunk = make_sanitized_chunk(
573 &task_id,
574 &def_name,
575 emit_seq,
576 SanitizedChunkKind::Terminal(state),
577 );
578 dispatch(&chunk, surfaces, &buffer);
579 break;
580 }
581 }
582 } else {
583 tracing::warn!(
584 task_id = %task_id,
585 "subagent.forward.terminal: ingress channel closed without an explicit \
586 terminal chunk — synthesizing hard-abort backstop"
587 );
588 flush_all_pending(
589 &mut pending,
590 &task_id,
591 &def_name,
592 &layers,
593 surfaces,
594 &buffer,
595 &mut dispatch,
596 &mut emit_seq,
597 );
598 let synthesized = make_sanitized_chunk(
599 &task_id,
600 &def_name,
601 emit_seq,
602 SanitizedChunkKind::Terminal(SubAgentState::Canceled),
603 );
604 dispatch(&synthesized, surfaces, &buffer);
605 break;
606 }
607 }
608
609 tokio::time::sleep(FORWARD_BUFFER_GRACE).await;
610 buffer
611 .lock()
612 .unwrap_or_else(std::sync::PoisonError::into_inner)
613 .remove(task_id.as_ref());
614}
615
616pub(crate) fn forwarded_tail(buffer: &ForwardBuffer, task_id: &str, n: usize) -> Vec<String> {
620 let guard = buffer
621 .lock()
622 .unwrap_or_else(std::sync::PoisonError::into_inner);
623 guard.get(task_id).map_or_else(Vec::new, |ring| {
624 ring.iter().rev().take(n).rev().cloned().collect()
625 })
626}
627
628pub(crate) fn new_buffer() -> Arc<ForwardBuffer> {
630 Arc::new(std::sync::Mutex::new(HashMap::new()))
631}
632
633#[cfg(test)]
634mod tests {
635 use std::sync::atomic::AtomicUsize;
636
637 use zeph_config::sanitizer::PiiFilterConfig;
638
639 use super::*;
640
641 fn layers() -> SanitizeLayers {
642 SanitizeLayers {
643 sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
644 secret_registry: None,
645 pii_filter: None,
646 }
647 }
648
649 async fn run_and_count_terminals(
657 task_id: Arc<str>,
658 def_name: Arc<str>,
659 rx: mpsc::Receiver<RawChunk>,
660 surfaces: ForwardSurfaces,
661 buffer: Arc<ForwardBuffer>,
662 ) -> usize {
663 let terminal_dispatches = Arc::new(AtomicUsize::new(0));
664 let counter = Arc::clone(&terminal_dispatches);
665 run_forward_drain_with(
666 task_id,
667 def_name,
668 rx,
669 layers(),
670 surfaces,
671 buffer,
672 move |chunk, surfaces, buffer| {
673 if matches!(chunk.kind, SanitizedChunkKind::Terminal(_)) {
674 counter.fetch_add(1, Ordering::SeqCst);
675 }
676 dispatch_chunk(chunk, surfaces, buffer);
677 },
678 )
679 .await;
680 terminal_dispatches.load(Ordering::SeqCst)
681 }
682
683 #[tokio::test(start_paused = true)]
684 async fn happy_path_emits_no_spurious_second_terminal() {
685 let task_id: Arc<str> = Arc::from("task-1");
691 let def_name: Arc<str> = Arc::from("agent-1");
692 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
693 let buffer = new_buffer();
694
695 sender.send_text("hello");
696 sender.send_terminal(SubAgentState::Completed);
697 drop(sender);
698
699 let terminal_count = run_and_count_terminals(
700 Arc::clone(&task_id),
701 def_name,
702 rx,
703 ForwardSurfaces {
704 tui: true,
705 bare: false,
706 },
707 Arc::clone(&buffer),
708 )
709 .await;
710
711 assert_eq!(
712 terminal_count, 1,
713 "exactly one terminal chunk must be dispatched — a second would mean the drain \
714 looped back to recv() after the explicit terminal (C-new-1 regression)"
715 );
716 let tail = forwarded_tail(&buffer, &task_id, 10);
717 assert!(
718 tail.is_empty(),
719 "buffer entry must be evicted after grace window"
720 );
721 }
722
723 #[tokio::test(start_paused = true)]
724 async fn hard_abort_without_explicit_terminal_synthesizes_backstop() {
725 let task_id: Arc<str> = Arc::from("task-2");
726 let def_name: Arc<str> = Arc::from("agent-2");
727 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
728 let buffer = new_buffer();
729
730 sender.send_text("partial output");
731 drop(sender); let terminal_count = run_and_count_terminals(
734 Arc::clone(&task_id),
735 def_name,
736 rx,
737 ForwardSurfaces {
738 tui: true,
739 bare: false,
740 },
741 buffer,
742 )
743 .await;
744
745 assert_eq!(
746 terminal_count, 1,
747 "exactly one synthesized backstop terminal must be dispatched on hard abort"
748 );
749 }
750
751 #[tokio::test(start_paused = true)]
752 async fn zero_consumer_surfaces_still_drains_without_panicking() {
753 let task_id: Arc<str> = Arc::from("task-3");
754 let def_name: Arc<str> = Arc::from("agent-3");
755 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
756 let buffer = new_buffer();
757
758 sender.send_text("no one is listening");
759 sender.send_terminal(SubAgentState::Completed);
760 drop(sender);
761
762 run_forward_drain(
763 task_id,
764 def_name,
765 rx,
766 layers(),
767 ForwardSurfaces::default(),
768 buffer,
769 )
770 .await;
771 }
772
773 #[tokio::test(start_paused = true)]
774 async fn secret_registry_masks_forwarded_text_and_thinking() {
775 use zeph_sanitizer::secret_mask::{SecretCategory, SecretMaskRegistry};
778
779 let registry = Arc::new(SecretMaskRegistry::new());
780 registry.register(
781 "MY_KEY",
782 "sk-live-topsecretvalue123",
783 SecretCategory::ApiKey,
784 );
785
786 let task_id: Arc<str> = Arc::from("task-secret");
787 let def_name: Arc<str> = Arc::from("agent-secret");
788 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
789 let buffer = new_buffer();
790
791 sender.send_text("the key is sk-live-topsecretvalue123, use it wisely");
792 sender.send_thinking("I will use sk-live-topsecretvalue123 to authenticate");
793 sender.send_terminal(SubAgentState::Completed);
794 drop(sender);
795
796 let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
797 let collected = Arc::clone(&seen);
798 let layers = SanitizeLayers {
799 sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
800 secret_registry: Some(registry),
801 pii_filter: None,
802 };
803 run_forward_drain_with(
804 task_id,
805 def_name,
806 rx,
807 layers,
808 ForwardSurfaces {
809 tui: true,
810 bare: false,
811 },
812 buffer,
813 move |chunk, surfaces, buffer| {
814 collected.lock().unwrap().push(chunk.clone());
815 dispatch_chunk(chunk, surfaces, buffer);
816 },
817 )
818 .await;
819
820 let chunks = seen.lock().unwrap();
821 for chunk in chunks.iter() {
822 match &chunk.kind {
823 SanitizedChunkKind::Text(t) | SanitizedChunkKind::Thinking(t) => {
824 assert!(
825 !t.contains("sk-live-topsecretvalue123"),
826 "forwarded content must not contain the raw secret: {t}"
827 );
828 }
829 SanitizedChunkKind::Terminal(_) => {}
830 }
831 }
832 }
833
834 #[tokio::test(start_paused = true)]
835 async fn registered_secret_that_also_matches_a_shape_gets_typed_placeholder_not_generic() {
836 use zeph_sanitizer::secret_mask::{SecretCategory, SecretMaskRegistry};
844
845 let registry = Arc::new(SecretMaskRegistry::new());
846 registry.register(
847 "MY_KEY",
848 "sk-live-topsecretvalue123",
849 SecretCategory::ApiKey,
850 );
851
852 let task_id: Arc<str> = Arc::from("task-secret-typed");
853 let def_name: Arc<str> = Arc::from("agent-secret-typed");
854 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
855 let buffer = new_buffer();
856
857 sender.send_text("the key is sk-live-topsecretvalue123, use it wisely");
858 sender.send_terminal(SubAgentState::Completed);
859 drop(sender);
860
861 let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
862 let collected = Arc::clone(&seen);
863 let layers = SanitizeLayers {
864 sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
865 secret_registry: Some(registry),
866 pii_filter: None,
867 };
868 run_forward_drain_with(
869 task_id,
870 def_name,
871 rx,
872 layers,
873 ForwardSurfaces {
874 tui: true,
875 bare: false,
876 },
877 buffer,
878 move |chunk, surfaces, buffer| {
879 collected.lock().unwrap().push(chunk.clone());
880 dispatch_chunk(chunk, surfaces, buffer);
881 },
882 )
883 .await;
884
885 let combined = collect_forwarded_text(&seen.lock().unwrap());
886 assert!(
887 !combined.contains("sk-live-topsecretvalue123"),
888 "raw secret must not survive the pipeline: {combined}"
889 );
890 assert!(
891 combined.contains("<SECRET:api_key:"),
892 "registry masking must run first and produce its typed placeholder: {combined}"
893 );
894 assert!(
895 !combined.contains("[REDACTED]"),
896 "shape scrub must not double-mask the registry's own placeholder output: {combined}"
897 );
898 }
899
900 #[tokio::test(start_paused = true)]
901 async fn generic_secret_shape_masked_without_registration() {
902 let task_id: Arc<str> = Arc::from("task-shape");
907 let def_name: Arc<str> = Arc::from("agent-shape");
908 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
909 let buffer = new_buffer();
910
911 sender.send_text("here is a key: sk-test-abc123def456, use it wisely");
912 sender.send_terminal(SubAgentState::Completed);
913 drop(sender);
914
915 let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
916 let collected = Arc::clone(&seen);
917 run_forward_drain_with(
918 task_id,
919 def_name,
920 rx,
921 layers(),
922 ForwardSurfaces {
923 tui: true,
924 bare: false,
925 },
926 buffer,
927 move |chunk, surfaces, buffer| {
928 collected.lock().unwrap().push(chunk.clone());
929 dispatch_chunk(chunk, surfaces, buffer);
930 },
931 )
932 .await;
933
934 let combined = collect_forwarded_text(&seen.lock().unwrap());
935 assert!(
936 !combined.contains("sk-test-abc123def456"),
937 "generic secret-shaped string must be masked without prior registration: {combined}"
938 );
939 assert!(
940 combined.contains("[REDACTED]"),
941 "masked placeholder must be present in the combined forwarded text: {combined}"
942 );
943 }
944
945 #[tokio::test(start_paused = true)]
946 async fn pii_filter_scrubs_forwarded_email() {
947 let task_id: Arc<str> = Arc::from("task-pii");
950 let def_name: Arc<str> = Arc::from("agent-pii");
951 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
952 let buffer = new_buffer();
953
954 sender.send_text("contact me at victim@example.com for details");
955 sender.send_terminal(SubAgentState::Completed);
956 drop(sender);
957
958 let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
959 let collected = Arc::clone(&seen);
960 let layers = SanitizeLayers {
961 sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
962 secret_registry: None,
963 pii_filter: Some(PiiFilter::new(PiiFilterConfig::default())),
964 };
965 run_forward_drain_with(
966 task_id,
967 def_name,
968 rx,
969 layers,
970 ForwardSurfaces {
971 tui: true,
972 bare: false,
973 },
974 buffer,
975 move |chunk, surfaces, buffer| {
976 collected.lock().unwrap().push(chunk.clone());
977 dispatch_chunk(chunk, surfaces, buffer);
978 },
979 )
980 .await;
981
982 let chunks = seen.lock().unwrap();
983 let text_chunk = chunks
984 .iter()
985 .find(|c| matches!(c.kind, SanitizedChunkKind::Text(_)))
986 .expect("one text chunk must have been dispatched");
987 let SanitizedChunkKind::Text(ref t) = text_chunk.kind else {
988 unreachable!()
989 };
990 assert!(
991 !t.contains("victim@example.com"),
992 "forwarded content must not contain the raw email address: {t}"
993 );
994 }
995
996 fn collect_forwarded_text(chunks: &[SanitizedChunk]) -> String {
999 chunks
1000 .iter()
1001 .filter_map(|c| match &c.kind {
1002 SanitizedChunkKind::Text(t) => Some(t.as_str()),
1003 _ => None,
1004 })
1005 .collect()
1006 }
1007
1008 #[tokio::test(start_paused = true)]
1009 async fn secret_split_across_two_deltas_is_still_masked() {
1010 use zeph_sanitizer::secret_mask::{SecretCategory, SecretMaskRegistry};
1016
1017 let secret_value = "sk-live-topsecretvalue123456789";
1018 let registry = Arc::new(SecretMaskRegistry::new());
1019 registry.register("MY_KEY", secret_value, SecretCategory::ApiKey);
1020 let (first_half, second_half) = secret_value.split_at(secret_value.len() / 2);
1021
1022 let task_id: Arc<str> = Arc::from("task-split");
1023 let def_name: Arc<str> = Arc::from("agent-split");
1024 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
1025 let buffer = new_buffer();
1026
1027 sender.send_text(&format!("the key is {first_half}"));
1028 sender.send_text(&format!("{second_half}, use it wisely"));
1029 sender.send_terminal(SubAgentState::Completed);
1030 drop(sender);
1031
1032 let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
1033 let collected = Arc::clone(&seen);
1034 let layers = SanitizeLayers {
1035 sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
1036 secret_registry: Some(registry),
1037 pii_filter: None,
1038 };
1039 run_forward_drain_with(
1040 task_id,
1041 def_name,
1042 rx,
1043 layers,
1044 ForwardSurfaces {
1045 tui: true,
1046 bare: false,
1047 },
1048 buffer,
1049 move |chunk, surfaces, buffer| {
1050 collected.lock().unwrap().push(chunk.clone());
1051 dispatch_chunk(chunk, surfaces, buffer);
1052 },
1053 )
1054 .await;
1055
1056 let combined = collect_forwarded_text(&seen.lock().unwrap());
1057 assert!(
1058 !combined.contains(secret_value),
1059 "secret split across two forwarded deltas must still be masked: {combined}"
1060 );
1061 assert!(
1062 combined.contains("<SECRET:api_key:"),
1063 "masked placeholder must be present in the combined forwarded text: {combined}"
1064 );
1065 }
1066
1067 #[tokio::test(start_paused = true)]
1068 async fn email_split_across_two_deltas_is_still_scrubbed() {
1069 let email = "victim@example.com";
1072 let (first_half, second_half) = email.split_at(email.len() / 2);
1073
1074 let task_id: Arc<str> = Arc::from("task-split-pii");
1075 let def_name: Arc<str> = Arc::from("agent-split-pii");
1076 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
1077 let buffer = new_buffer();
1078
1079 sender.send_text(&format!("contact me at {first_half}"));
1080 sender.send_text(&format!("{second_half} for details"));
1081 sender.send_terminal(SubAgentState::Completed);
1082 drop(sender);
1083
1084 let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
1085 let collected = Arc::clone(&seen);
1086 let layers = SanitizeLayers {
1087 sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
1088 secret_registry: None,
1089 pii_filter: Some(PiiFilter::new(PiiFilterConfig::default())),
1090 };
1091 run_forward_drain_with(
1092 task_id,
1093 def_name,
1094 rx,
1095 layers,
1096 ForwardSurfaces {
1097 tui: true,
1098 bare: false,
1099 },
1100 buffer,
1101 move |chunk, surfaces, buffer| {
1102 collected.lock().unwrap().push(chunk.clone());
1103 dispatch_chunk(chunk, surfaces, buffer);
1104 },
1105 )
1106 .await;
1107
1108 let combined = collect_forwarded_text(&seen.lock().unwrap());
1109 assert!(
1110 !combined.contains(email),
1111 "email split across two forwarded deltas must still be scrubbed: {combined}"
1112 );
1113 }
1114
1115 #[tokio::test(start_paused = true)]
1116 async fn secret_split_across_progressive_flush_boundary_is_still_masked() {
1117 use zeph_sanitizer::secret_mask::{SecretCategory, SecretMaskRegistry};
1125
1126 let secret_value = "sk-live-anothersecretvalue987654321";
1127 let registry = Arc::new(SecretMaskRegistry::new());
1128 registry.register("MY_KEY", secret_value, SecretCategory::ApiKey);
1129 let (first_half, second_half) = secret_value.split_at(secret_value.len() / 2);
1130
1131 let task_id: Arc<str> = Arc::from("task-window");
1132 let def_name: Arc<str> = Arc::from("agent-window");
1133 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
1134 let buffer = new_buffer();
1135
1136 for i in 0..40 {
1137 sender.send_text(&format!("filler-chunk-{i:03} "));
1138 }
1139 sender.send_text(first_half);
1140 sender.send_text(second_half);
1141 sender.send_terminal(SubAgentState::Completed);
1142 drop(sender);
1143
1144 let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
1145 let collected = Arc::clone(&seen);
1146 let layers = SanitizeLayers {
1147 sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
1148 secret_registry: Some(registry),
1149 pii_filter: None,
1150 };
1151 run_forward_drain_with(
1152 task_id,
1153 def_name,
1154 rx,
1155 layers,
1156 ForwardSurfaces {
1157 tui: true,
1158 bare: false,
1159 },
1160 buffer,
1161 move |chunk, surfaces, buffer| {
1162 collected.lock().unwrap().push(chunk.clone());
1163 dispatch_chunk(chunk, surfaces, buffer);
1164 },
1165 )
1166 .await;
1167
1168 let seen = seen.lock().unwrap();
1169 let text_chunk_count = seen
1170 .iter()
1171 .filter(|c| matches!(c.kind, SanitizedChunkKind::Text(_)))
1172 .count();
1173 assert!(
1174 text_chunk_count > 1,
1175 "filler well over the holdback window must have produced at least one \
1176 progressive flush before the terminal-triggered final flush, got \
1177 {text_chunk_count} text chunk(s)"
1178 );
1179 let combined = collect_forwarded_text(&seen);
1180 assert!(
1181 !combined.contains(secret_value),
1182 "secret split across the streaming boundary must still be masked: {combined}"
1183 );
1184 }
1185
1186 #[tokio::test(start_paused = true)]
1187 async fn buffer_entry_survives_during_grace_window_then_evicted() {
1188 let task_id: Arc<str> = Arc::from("task-grace");
1192 let def_name: Arc<str> = Arc::from("agent-grace");
1193 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
1194 let buffer = new_buffer();
1195
1196 sender.send_text("visible during the grace window");
1197 sender.send_terminal(SubAgentState::Completed);
1198 drop(sender);
1199
1200 let drain_buffer = Arc::clone(&buffer);
1201 let drain_task_id = Arc::clone(&task_id);
1202 let handle = tokio::spawn(run_forward_drain(
1203 drain_task_id,
1204 def_name,
1205 rx,
1206 layers(),
1207 ForwardSurfaces {
1208 tui: true,
1209 bare: false,
1210 },
1211 drain_buffer,
1212 ));
1213
1214 tokio::time::advance(Duration::from_millis(1)).await;
1216 tokio::task::yield_now().await;
1217
1218 let mid_window_tail = forwarded_tail(&buffer, &task_id, 10);
1219 assert_eq!(
1220 mid_window_tail.len(),
1221 1,
1222 "exactly one forwarded line expected"
1223 );
1224 assert!(
1225 mid_window_tail[0].contains("visible during the grace window"),
1226 "the transcript must still be visible during the grace window, got: {:?}",
1227 mid_window_tail[0]
1228 );
1229
1230 tokio::time::advance(FORWARD_BUFFER_GRACE + Duration::from_millis(1)).await;
1231 handle.await.expect("drain task must not panic");
1232
1233 let post_eviction_tail = forwarded_tail(&buffer, &task_id, 10);
1234 assert!(
1235 post_eviction_tail.is_empty(),
1236 "buffer entry must be evicted once the grace window elapses"
1237 );
1238 }
1239
1240 #[test]
1241 fn empty_text_is_not_sent() {
1242 let task_id: Arc<str> = Arc::from("task-4");
1243 let def_name: Arc<str> = Arc::from("agent-4");
1244 let (sender, mut rx) = new_channel(task_id, def_name);
1245 sender.send_text("");
1246 sender.send_thinking("");
1247 drop(sender);
1248 assert!(
1249 rx.try_recv().is_err(),
1250 "empty text/thinking must not be sent onto the ingress channel"
1251 );
1252 }
1253
1254 #[test]
1255 fn channel_full_increments_drop_counter_and_does_not_panic() {
1256 let task_id: Arc<str> = Arc::from("task-5");
1257 let def_name: Arc<str> = Arc::from("agent-5");
1258 let (sender, mut rx) = new_channel(task_id, def_name);
1259 for i in 0..FORWARD_CHANNEL_CAPACITY + 10 {
1260 sender.send_text(&format!("chunk {i}"));
1261 }
1262 let mut received = 0;
1264 while rx.try_recv().is_ok() {
1265 received += 1;
1266 }
1267 assert!(
1268 received > 0,
1269 "at least some chunks must have been delivered"
1270 );
1271 assert!(
1272 received <= FORWARD_CHANNEL_CAPACITY,
1273 "received must never exceed channel capacity"
1274 );
1275 }
1276
1277 #[test]
1278 fn forward_surfaces_any() {
1279 assert!(!ForwardSurfaces::default().any());
1280 assert!(
1281 ForwardSurfaces {
1282 tui: true,
1283 bare: false
1284 }
1285 .any()
1286 );
1287 assert!(
1288 ForwardSurfaces {
1289 tui: false,
1290 bare: true
1291 }
1292 .any()
1293 );
1294 }
1295}