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
192const PEM_HOLDBACK_CAP_BYTES: usize = zeph_common::secrets::PEM_BODY_CAP;
213
214#[derive(Default)]
219struct PendingSanitizeBuffers {
220 text: String,
221 thinking: String,
222}
223
224fn last_header_before(buf: &str, before: usize) -> Option<usize> {
227 let region = &buf[..before];
228 [region.rfind("-----BEGIN"), region.rfind("---- BEGIN")]
229 .into_iter()
230 .flatten()
231 .max()
232}
233
234fn footer_end_after(buf: &str, marker_idx: usize) -> Option<usize> {
237 let tail = &buf[marker_idx..];
238 let end_offset = [tail.find("-----END"), tail.find("---- END")]
239 .into_iter()
240 .flatten()
241 .min()?;
242 Some(marker_idx + end_offset + 8)
243}
244
245fn pem_safe_flush_target(buf: &str, natural_target: usize) -> usize {
276 let mut candidate = natural_target;
277 loop {
278 let Some(marker_idx) = last_header_before(buf, candidate) else {
279 return candidate;
280 };
281 match footer_end_after(buf, marker_idx) {
282 Some(block_end) if block_end <= candidate => return candidate,
283 Some(_) => candidate = marker_idx,
284 None => {
285 let capped = buf.len().saturating_sub(PEM_HOLDBACK_CAP_BYTES);
286 return marker_idx.max(capped);
287 }
288 }
289 }
290}
291
292fn split_off_safe_prefix(buf: &mut String, holdback: usize) -> Option<String> {
311 if buf.is_empty() {
312 return None;
313 }
314 let target = if holdback == 0 {
315 buf.len()
316 } else {
317 let natural_target = buf.floor_char_boundary(buf.len().saturating_sub(holdback));
329 pem_safe_flush_target(buf, natural_target)
330 };
331 let boundary = buf.floor_char_boundary(target.min(buf.len()));
332 if boundary == 0 {
333 return None;
334 }
335 let prefix = buf[..boundary].to_owned();
336 buf.drain(..boundary);
337 Some(prefix)
338}
339
340fn try_flush_kind(
345 buf: &mut String,
346 holdback: usize,
347 def_name: &str,
348 layers: &SanitizeLayers,
349 wrap_kind: fn(String) -> SanitizedChunkKind,
350) -> Option<SanitizedChunkKind> {
351 let safe = split_off_safe_prefix(buf, holdback)?;
352 Some(wrap_kind(sanitize_text(&safe, def_name, layers)))
353}
354
355fn make_sanitized_chunk(
356 task_id: &Arc<str>,
357 def_name: &Arc<str>,
358 seq: u64,
359 kind: SanitizedChunkKind,
360) -> SanitizedChunk {
361 SanitizedChunk {
362 task_id: Arc::clone(task_id),
363 def_name: Arc::clone(def_name),
364 seq,
365 kind,
366 }
367}
368
369#[allow(clippy::too_many_arguments)]
374fn flush_all_pending(
375 pending: &mut PendingSanitizeBuffers,
376 task_id: &Arc<str>,
377 def_name: &Arc<str>,
378 layers: &SanitizeLayers,
379 surfaces: ForwardSurfaces,
380 buffer: &ForwardBuffer,
381 dispatch: &mut impl FnMut(&SanitizedChunk, ForwardSurfaces, &ForwardBuffer),
382 emit_seq: &mut u64,
383) {
384 if let Some(kind) = try_flush_kind(
385 &mut pending.text,
386 0,
387 def_name.as_ref(),
388 layers,
389 SanitizedChunkKind::Text,
390 ) {
391 dispatch(
392 &make_sanitized_chunk(task_id, def_name, *emit_seq, kind),
393 surfaces,
394 buffer,
395 );
396 *emit_seq += 1;
397 }
398 if let Some(kind) = try_flush_kind(
399 &mut pending.thinking,
400 0,
401 def_name.as_ref(),
402 layers,
403 SanitizedChunkKind::Thinking,
404 ) {
405 dispatch(
406 &make_sanitized_chunk(task_id, def_name, *emit_seq, kind),
407 surfaces,
408 buffer,
409 );
410 *emit_seq += 1;
411 }
412}
413
414pub(crate) struct ForwardSender {
423 tx: mpsc::Sender<RawChunk>,
424 task_id: Arc<str>,
425 def_name: Arc<str>,
426 seq: AtomicU64,
427 dropped: AtomicU64,
428}
429
430impl ForwardSender {
431 pub(crate) fn new(tx: mpsc::Sender<RawChunk>, task_id: Arc<str>, def_name: Arc<str>) -> Self {
432 Self {
433 tx,
434 task_id,
435 def_name,
436 seq: AtomicU64::new(0),
437 dropped: AtomicU64::new(0),
438 }
439 }
440
441 fn try_send(&self, kind: ForwardChunkKind) {
442 let seq = self.seq.fetch_add(1, Ordering::Relaxed);
443 let chunk = RawChunk { kind };
444 if self.tx.try_send(chunk).is_ok() {
445 tracing::debug!(
446 task_id = %self.task_id,
447 def_name = %self.def_name,
448 seq,
449 "subagent.forward.emit"
450 );
451 } else {
452 let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1;
453 tracing::warn!(
454 task_id = %self.task_id,
455 def_name = %self.def_name,
456 seq,
457 dropped,
458 "subagent.forward.drop: ingress channel full, chunk dropped"
459 );
460 }
461 }
462
463 pub(crate) fn send_text(&self, text: &str) {
475 if text.is_empty() {
476 return;
477 }
478 self.try_send(ForwardChunkKind::Text(text.to_owned()));
479 }
480
481 pub(crate) fn send_thinking(&self, text: &str) {
485 if text.is_empty() {
486 return;
487 }
488 self.try_send(ForwardChunkKind::Thinking(text.to_owned()));
489 }
490
491 pub(crate) fn send_terminal(&self, state: SubAgentState) {
494 tracing::debug!(task_id = %self.task_id, ?state, "subagent.forward.terminal");
495 self.try_send(ForwardChunkKind::Terminal(state));
496 }
497}
498
499pub(crate) type ForwardBuffer = std::sync::Mutex<HashMap<String, VecDeque<String>>>;
500
501fn display_line(kind: &SanitizedChunkKind) -> Option<String> {
504 match kind {
505 SanitizedChunkKind::Text(t) => Some(t.clone()),
506 SanitizedChunkKind::Thinking(t) => Some(format!("[thinking] {t}")),
507 SanitizedChunkKind::Terminal(_) => None,
508 }
509}
510
511fn state_str(state: SubAgentState) -> &'static str {
512 match state {
513 SubAgentState::Submitted => "submitted",
514 SubAgentState::Working => "working",
515 SubAgentState::Completed => "completed",
516 SubAgentState::Failed => "failed",
517 SubAgentState::Canceled => "canceled",
518 }
519}
520
521fn emit_bare_line(chunk: &SanitizedChunk) {
525 #[derive(serde::Serialize)]
526 struct BareForwardEvent<'a> {
527 task_id: &'a str,
528 def_name: &'a str,
529 seq: u64,
530 kind: &'static str,
531 #[serde(skip_serializing_if = "Option::is_none")]
532 content: Option<&'a str>,
533 #[serde(skip_serializing_if = "Option::is_none")]
534 state: Option<&'static str>,
535 }
536
537 let (kind, content, state) = match &chunk.kind {
538 SanitizedChunkKind::Text(t) => ("text", Some(t.as_str()), None),
539 SanitizedChunkKind::Thinking(t) => ("thinking", Some(t.as_str()), None),
540 SanitizedChunkKind::Terminal(s) => ("terminal", None, Some(state_str(*s))),
541 };
542 let event = BareForwardEvent {
543 task_id: &chunk.task_id,
544 def_name: &chunk.def_name,
545 seq: chunk.seq,
546 kind,
547 content,
548 state,
549 };
550 if let Ok(line) = serde_json::to_string(&event) {
551 println!("{line}");
552 }
553}
554
555fn dispatch_chunk(chunk: &SanitizedChunk, surfaces: ForwardSurfaces, buffer: &ForwardBuffer) {
557 if surfaces.tui
558 && let Some(line) = display_line(&chunk.kind)
559 {
560 let mut guard = buffer
561 .lock()
562 .unwrap_or_else(std::sync::PoisonError::into_inner);
563 let ring = guard.entry(chunk.task_id.to_string()).or_default();
564 ring.push_back(line);
565 while ring.len() > FORWARD_RING_CAPACITY {
566 ring.pop_front();
567 }
568 }
569 if surfaces.bare {
570 emit_bare_line(chunk);
571 }
572}
573
574pub(crate) fn new_channel(
576 task_id: Arc<str>,
577 def_name: Arc<str>,
578) -> (ForwardSender, mpsc::Receiver<RawChunk>) {
579 let (tx, rx) = mpsc::channel(FORWARD_CHANNEL_CAPACITY);
580 (ForwardSender::new(tx, task_id, def_name), rx)
581}
582
583pub(crate) async fn run_forward_drain(
604 task_id: Arc<str>,
605 def_name: Arc<str>,
606 rx: mpsc::Receiver<RawChunk>,
607 layers: SanitizeLayers,
608 surfaces: ForwardSurfaces,
609 buffer: Arc<ForwardBuffer>,
610) {
611 run_forward_drain_with(
612 task_id,
613 def_name,
614 rx,
615 layers,
616 surfaces,
617 buffer,
618 dispatch_chunk,
619 )
620 .await;
621}
622
623async fn run_forward_drain_with(
631 task_id: Arc<str>,
632 def_name: Arc<str>,
633 mut rx: mpsc::Receiver<RawChunk>,
634 layers: SanitizeLayers,
635 surfaces: ForwardSurfaces,
636 buffer: Arc<ForwardBuffer>,
637 mut dispatch: impl FnMut(&SanitizedChunk, ForwardSurfaces, &ForwardBuffer),
638) {
639 let mut pending = PendingSanitizeBuffers::default();
640 let mut emit_seq: u64 = 0;
641
642 loop {
643 if let Some(raw) = rx.recv().await {
644 match raw.kind {
645 ForwardChunkKind::Text(delta) => {
646 pending.text.push_str(&delta);
647 if let Some(kind) = try_flush_kind(
648 &mut pending.text,
649 SANITIZE_HOLDBACK_BYTES,
650 def_name.as_ref(),
651 &layers,
652 SanitizedChunkKind::Text,
653 ) {
654 dispatch(
655 &make_sanitized_chunk(&task_id, &def_name, emit_seq, kind),
656 surfaces,
657 &buffer,
658 );
659 emit_seq += 1;
660 }
661 }
662 ForwardChunkKind::Thinking(delta) => {
663 pending.thinking.push_str(&delta);
664 if let Some(kind) = try_flush_kind(
665 &mut pending.thinking,
666 SANITIZE_HOLDBACK_BYTES,
667 def_name.as_ref(),
668 &layers,
669 SanitizedChunkKind::Thinking,
670 ) {
671 dispatch(
672 &make_sanitized_chunk(&task_id, &def_name, emit_seq, kind),
673 surfaces,
674 &buffer,
675 );
676 emit_seq += 1;
677 }
678 }
679 ForwardChunkKind::Terminal(state) => {
680 flush_all_pending(
681 &mut pending,
682 &task_id,
683 &def_name,
684 &layers,
685 surfaces,
686 &buffer,
687 &mut dispatch,
688 &mut emit_seq,
689 );
690 let chunk = make_sanitized_chunk(
691 &task_id,
692 &def_name,
693 emit_seq,
694 SanitizedChunkKind::Terminal(state),
695 );
696 dispatch(&chunk, surfaces, &buffer);
697 break;
698 }
699 }
700 } else {
701 tracing::warn!(
702 task_id = %task_id,
703 "subagent.forward.terminal: ingress channel closed without an explicit \
704 terminal chunk — synthesizing hard-abort backstop"
705 );
706 flush_all_pending(
707 &mut pending,
708 &task_id,
709 &def_name,
710 &layers,
711 surfaces,
712 &buffer,
713 &mut dispatch,
714 &mut emit_seq,
715 );
716 let synthesized = make_sanitized_chunk(
717 &task_id,
718 &def_name,
719 emit_seq,
720 SanitizedChunkKind::Terminal(SubAgentState::Canceled),
721 );
722 dispatch(&synthesized, surfaces, &buffer);
723 break;
724 }
725 }
726
727 tokio::time::sleep(FORWARD_BUFFER_GRACE).await;
728 buffer
729 .lock()
730 .unwrap_or_else(std::sync::PoisonError::into_inner)
731 .remove(task_id.as_ref());
732}
733
734pub(crate) fn forwarded_tail(buffer: &ForwardBuffer, task_id: &str, n: usize) -> Vec<String> {
738 let guard = buffer
739 .lock()
740 .unwrap_or_else(std::sync::PoisonError::into_inner);
741 guard.get(task_id).map_or_else(Vec::new, |ring| {
742 ring.iter().rev().take(n).rev().cloned().collect()
743 })
744}
745
746pub(crate) fn new_buffer() -> Arc<ForwardBuffer> {
748 Arc::new(std::sync::Mutex::new(HashMap::new()))
749}
750
751#[cfg(test)]
752mod tests {
753 use std::sync::atomic::AtomicUsize;
754
755 use zeph_config::sanitizer::PiiFilterConfig;
756
757 use super::*;
758
759 fn layers() -> SanitizeLayers {
760 SanitizeLayers {
761 sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
762 secret_registry: None,
763 pii_filter: None,
764 }
765 }
766
767 async fn run_and_count_terminals(
775 task_id: Arc<str>,
776 def_name: Arc<str>,
777 rx: mpsc::Receiver<RawChunk>,
778 surfaces: ForwardSurfaces,
779 buffer: Arc<ForwardBuffer>,
780 ) -> usize {
781 let terminal_dispatches = Arc::new(AtomicUsize::new(0));
782 let counter = Arc::clone(&terminal_dispatches);
783 run_forward_drain_with(
784 task_id,
785 def_name,
786 rx,
787 layers(),
788 surfaces,
789 buffer,
790 move |chunk, surfaces, buffer| {
791 if matches!(chunk.kind, SanitizedChunkKind::Terminal(_)) {
792 counter.fetch_add(1, Ordering::SeqCst);
793 }
794 dispatch_chunk(chunk, surfaces, buffer);
795 },
796 )
797 .await;
798 terminal_dispatches.load(Ordering::SeqCst)
799 }
800
801 #[tokio::test(start_paused = true)]
802 async fn happy_path_emits_no_spurious_second_terminal() {
803 let task_id: Arc<str> = Arc::from("task-1");
809 let def_name: Arc<str> = Arc::from("agent-1");
810 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
811 let buffer = new_buffer();
812
813 sender.send_text("hello");
814 sender.send_terminal(SubAgentState::Completed);
815 drop(sender);
816
817 let terminal_count = run_and_count_terminals(
818 Arc::clone(&task_id),
819 def_name,
820 rx,
821 ForwardSurfaces {
822 tui: true,
823 bare: false,
824 },
825 Arc::clone(&buffer),
826 )
827 .await;
828
829 assert_eq!(
830 terminal_count, 1,
831 "exactly one terminal chunk must be dispatched — a second would mean the drain \
832 looped back to recv() after the explicit terminal (C-new-1 regression)"
833 );
834 let tail = forwarded_tail(&buffer, &task_id, 10);
835 assert!(
836 tail.is_empty(),
837 "buffer entry must be evicted after grace window"
838 );
839 }
840
841 #[tokio::test(start_paused = true)]
842 async fn hard_abort_without_explicit_terminal_synthesizes_backstop() {
843 let task_id: Arc<str> = Arc::from("task-2");
844 let def_name: Arc<str> = Arc::from("agent-2");
845 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
846 let buffer = new_buffer();
847
848 sender.send_text("partial output");
849 drop(sender); let terminal_count = run_and_count_terminals(
852 Arc::clone(&task_id),
853 def_name,
854 rx,
855 ForwardSurfaces {
856 tui: true,
857 bare: false,
858 },
859 buffer,
860 )
861 .await;
862
863 assert_eq!(
864 terminal_count, 1,
865 "exactly one synthesized backstop terminal must be dispatched on hard abort"
866 );
867 }
868
869 #[tokio::test(start_paused = true)]
870 async fn zero_consumer_surfaces_still_drains_without_panicking() {
871 let task_id: Arc<str> = Arc::from("task-3");
872 let def_name: Arc<str> = Arc::from("agent-3");
873 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
874 let buffer = new_buffer();
875
876 sender.send_text("no one is listening");
877 sender.send_terminal(SubAgentState::Completed);
878 drop(sender);
879
880 run_forward_drain(
881 task_id,
882 def_name,
883 rx,
884 layers(),
885 ForwardSurfaces::default(),
886 buffer,
887 )
888 .await;
889 }
890
891 #[tokio::test(start_paused = true)]
892 async fn secret_registry_masks_forwarded_text_and_thinking() {
893 use zeph_sanitizer::secret_mask::{SecretCategory, SecretMaskRegistry};
896
897 let registry = Arc::new(SecretMaskRegistry::new());
898 registry.register(
899 "MY_KEY",
900 "sk-live-topsecretvalue123",
901 SecretCategory::ApiKey,
902 );
903
904 let task_id: Arc<str> = Arc::from("task-secret");
905 let def_name: Arc<str> = Arc::from("agent-secret");
906 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
907 let buffer = new_buffer();
908
909 sender.send_text("the key is sk-live-topsecretvalue123, use it wisely");
910 sender.send_thinking("I will use sk-live-topsecretvalue123 to authenticate");
911 sender.send_terminal(SubAgentState::Completed);
912 drop(sender);
913
914 let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
915 let collected = Arc::clone(&seen);
916 let layers = SanitizeLayers {
917 sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
918 secret_registry: Some(registry),
919 pii_filter: None,
920 };
921 run_forward_drain_with(
922 task_id,
923 def_name,
924 rx,
925 layers,
926 ForwardSurfaces {
927 tui: true,
928 bare: false,
929 },
930 buffer,
931 move |chunk, surfaces, buffer| {
932 collected.lock().unwrap().push(chunk.clone());
933 dispatch_chunk(chunk, surfaces, buffer);
934 },
935 )
936 .await;
937
938 let chunks = seen.lock().unwrap();
939 for chunk in chunks.iter() {
940 match &chunk.kind {
941 SanitizedChunkKind::Text(t) | SanitizedChunkKind::Thinking(t) => {
942 assert!(
943 !t.contains("sk-live-topsecretvalue123"),
944 "forwarded content must not contain the raw secret: {t}"
945 );
946 }
947 SanitizedChunkKind::Terminal(_) => {}
948 }
949 }
950 }
951
952 #[tokio::test(start_paused = true)]
953 async fn registered_secret_that_also_matches_a_shape_gets_typed_placeholder_not_generic() {
954 use zeph_sanitizer::secret_mask::{SecretCategory, SecretMaskRegistry};
962
963 let registry = Arc::new(SecretMaskRegistry::new());
964 registry.register(
965 "MY_KEY",
966 "sk-live-topsecretvalue123",
967 SecretCategory::ApiKey,
968 );
969
970 let task_id: Arc<str> = Arc::from("task-secret-typed");
971 let def_name: Arc<str> = Arc::from("agent-secret-typed");
972 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
973 let buffer = new_buffer();
974
975 sender.send_text("the key is sk-live-topsecretvalue123, use it wisely");
976 sender.send_terminal(SubAgentState::Completed);
977 drop(sender);
978
979 let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
980 let collected = Arc::clone(&seen);
981 let layers = SanitizeLayers {
982 sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
983 secret_registry: Some(registry),
984 pii_filter: None,
985 };
986 run_forward_drain_with(
987 task_id,
988 def_name,
989 rx,
990 layers,
991 ForwardSurfaces {
992 tui: true,
993 bare: false,
994 },
995 buffer,
996 move |chunk, surfaces, buffer| {
997 collected.lock().unwrap().push(chunk.clone());
998 dispatch_chunk(chunk, surfaces, buffer);
999 },
1000 )
1001 .await;
1002
1003 let combined = collect_forwarded_text(&seen.lock().unwrap());
1004 assert!(
1005 !combined.contains("sk-live-topsecretvalue123"),
1006 "raw secret must not survive the pipeline: {combined}"
1007 );
1008 assert!(
1009 combined.contains("<SECRET:api_key:"),
1010 "registry masking must run first and produce its typed placeholder: {combined}"
1011 );
1012 assert!(
1013 !combined.contains("[REDACTED]"),
1014 "shape scrub must not double-mask the registry's own placeholder output: {combined}"
1015 );
1016 }
1017
1018 #[tokio::test(start_paused = true)]
1019 async fn generic_secret_shape_masked_without_registration() {
1020 let task_id: Arc<str> = Arc::from("task-shape");
1025 let def_name: Arc<str> = Arc::from("agent-shape");
1026 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
1027 let buffer = new_buffer();
1028
1029 sender.send_text("here is a key: sk-test-abc123def456, use it wisely");
1030 sender.send_terminal(SubAgentState::Completed);
1031 drop(sender);
1032
1033 let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
1034 let collected = Arc::clone(&seen);
1035 run_forward_drain_with(
1036 task_id,
1037 def_name,
1038 rx,
1039 layers(),
1040 ForwardSurfaces {
1041 tui: true,
1042 bare: false,
1043 },
1044 buffer,
1045 move |chunk, surfaces, buffer| {
1046 collected.lock().unwrap().push(chunk.clone());
1047 dispatch_chunk(chunk, surfaces, buffer);
1048 },
1049 )
1050 .await;
1051
1052 let combined = collect_forwarded_text(&seen.lock().unwrap());
1053 assert!(
1054 !combined.contains("sk-test-abc123def456"),
1055 "generic secret-shaped string must be masked without prior registration: {combined}"
1056 );
1057 assert!(
1058 combined.contains("[REDACTED]"),
1059 "masked placeholder must be present in the combined forwarded text: {combined}"
1060 );
1061 }
1062
1063 #[tokio::test(start_paused = true)]
1064 async fn pii_filter_scrubs_forwarded_email() {
1065 let task_id: Arc<str> = Arc::from("task-pii");
1068 let def_name: Arc<str> = Arc::from("agent-pii");
1069 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
1070 let buffer = new_buffer();
1071
1072 sender.send_text("contact me at victim@example.com for details");
1073 sender.send_terminal(SubAgentState::Completed);
1074 drop(sender);
1075
1076 let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
1077 let collected = Arc::clone(&seen);
1078 let layers = SanitizeLayers {
1079 sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
1080 secret_registry: None,
1081 pii_filter: Some(PiiFilter::new(PiiFilterConfig::default())),
1082 };
1083 run_forward_drain_with(
1084 task_id,
1085 def_name,
1086 rx,
1087 layers,
1088 ForwardSurfaces {
1089 tui: true,
1090 bare: false,
1091 },
1092 buffer,
1093 move |chunk, surfaces, buffer| {
1094 collected.lock().unwrap().push(chunk.clone());
1095 dispatch_chunk(chunk, surfaces, buffer);
1096 },
1097 )
1098 .await;
1099
1100 let chunks = seen.lock().unwrap();
1101 let text_chunk = chunks
1102 .iter()
1103 .find(|c| matches!(c.kind, SanitizedChunkKind::Text(_)))
1104 .expect("one text chunk must have been dispatched");
1105 let SanitizedChunkKind::Text(ref t) = text_chunk.kind else {
1106 unreachable!()
1107 };
1108 assert!(
1109 !t.contains("victim@example.com"),
1110 "forwarded content must not contain the raw email address: {t}"
1111 );
1112 }
1113
1114 fn collect_forwarded_text(chunks: &[SanitizedChunk]) -> String {
1117 chunks
1118 .iter()
1119 .filter_map(|c| match &c.kind {
1120 SanitizedChunkKind::Text(t) => Some(t.as_str()),
1121 _ => None,
1122 })
1123 .collect()
1124 }
1125
1126 #[tokio::test(start_paused = true)]
1127 async fn secret_split_across_two_deltas_is_still_masked() {
1128 use zeph_sanitizer::secret_mask::{SecretCategory, SecretMaskRegistry};
1134
1135 let secret_value = "sk-live-topsecretvalue123456789";
1136 let registry = Arc::new(SecretMaskRegistry::new());
1137 registry.register("MY_KEY", secret_value, SecretCategory::ApiKey);
1138 let (first_half, second_half) = secret_value.split_at(secret_value.len() / 2);
1139
1140 let task_id: Arc<str> = Arc::from("task-split");
1141 let def_name: Arc<str> = Arc::from("agent-split");
1142 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
1143 let buffer = new_buffer();
1144
1145 sender.send_text(&format!("the key is {first_half}"));
1146 sender.send_text(&format!("{second_half}, use it wisely"));
1147 sender.send_terminal(SubAgentState::Completed);
1148 drop(sender);
1149
1150 let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
1151 let collected = Arc::clone(&seen);
1152 let layers = SanitizeLayers {
1153 sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
1154 secret_registry: Some(registry),
1155 pii_filter: None,
1156 };
1157 run_forward_drain_with(
1158 task_id,
1159 def_name,
1160 rx,
1161 layers,
1162 ForwardSurfaces {
1163 tui: true,
1164 bare: false,
1165 },
1166 buffer,
1167 move |chunk, surfaces, buffer| {
1168 collected.lock().unwrap().push(chunk.clone());
1169 dispatch_chunk(chunk, surfaces, buffer);
1170 },
1171 )
1172 .await;
1173
1174 let combined = collect_forwarded_text(&seen.lock().unwrap());
1175 assert!(
1176 !combined.contains(secret_value),
1177 "secret split across two forwarded deltas must still be masked: {combined}"
1178 );
1179 assert!(
1180 combined.contains("<SECRET:api_key:"),
1181 "masked placeholder must be present in the combined forwarded text: {combined}"
1182 );
1183 }
1184
1185 #[tokio::test(start_paused = true)]
1186 async fn email_split_across_two_deltas_is_still_scrubbed() {
1187 let email = "victim@example.com";
1190 let (first_half, second_half) = email.split_at(email.len() / 2);
1191
1192 let task_id: Arc<str> = Arc::from("task-split-pii");
1193 let def_name: Arc<str> = Arc::from("agent-split-pii");
1194 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
1195 let buffer = new_buffer();
1196
1197 sender.send_text(&format!("contact me at {first_half}"));
1198 sender.send_text(&format!("{second_half} for details"));
1199 sender.send_terminal(SubAgentState::Completed);
1200 drop(sender);
1201
1202 let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
1203 let collected = Arc::clone(&seen);
1204 let layers = SanitizeLayers {
1205 sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
1206 secret_registry: None,
1207 pii_filter: Some(PiiFilter::new(PiiFilterConfig::default())),
1208 };
1209 run_forward_drain_with(
1210 task_id,
1211 def_name,
1212 rx,
1213 layers,
1214 ForwardSurfaces {
1215 tui: true,
1216 bare: false,
1217 },
1218 buffer,
1219 move |chunk, surfaces, buffer| {
1220 collected.lock().unwrap().push(chunk.clone());
1221 dispatch_chunk(chunk, surfaces, buffer);
1222 },
1223 )
1224 .await;
1225
1226 let combined = collect_forwarded_text(&seen.lock().unwrap());
1227 assert!(
1228 !combined.contains(email),
1229 "email split across two forwarded deltas must still be scrubbed: {combined}"
1230 );
1231 }
1232
1233 #[tokio::test(start_paused = true)]
1234 async fn secret_split_across_progressive_flush_boundary_is_still_masked() {
1235 use zeph_sanitizer::secret_mask::{SecretCategory, SecretMaskRegistry};
1243
1244 let secret_value = "sk-live-anothersecretvalue987654321";
1245 let registry = Arc::new(SecretMaskRegistry::new());
1246 registry.register("MY_KEY", secret_value, SecretCategory::ApiKey);
1247 let (first_half, second_half) = secret_value.split_at(secret_value.len() / 2);
1248
1249 let task_id: Arc<str> = Arc::from("task-window");
1250 let def_name: Arc<str> = Arc::from("agent-window");
1251 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
1252 let buffer = new_buffer();
1253
1254 for i in 0..40 {
1255 sender.send_text(&format!("filler-chunk-{i:03} "));
1256 }
1257 sender.send_text(first_half);
1258 sender.send_text(second_half);
1259 sender.send_terminal(SubAgentState::Completed);
1260 drop(sender);
1261
1262 let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
1263 let collected = Arc::clone(&seen);
1264 let layers = SanitizeLayers {
1265 sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
1266 secret_registry: Some(registry),
1267 pii_filter: None,
1268 };
1269 run_forward_drain_with(
1270 task_id,
1271 def_name,
1272 rx,
1273 layers,
1274 ForwardSurfaces {
1275 tui: true,
1276 bare: false,
1277 },
1278 buffer,
1279 move |chunk, surfaces, buffer| {
1280 collected.lock().unwrap().push(chunk.clone());
1281 dispatch_chunk(chunk, surfaces, buffer);
1282 },
1283 )
1284 .await;
1285
1286 let seen = seen.lock().unwrap();
1287 let text_chunk_count = seen
1288 .iter()
1289 .filter(|c| matches!(c.kind, SanitizedChunkKind::Text(_)))
1290 .count();
1291 assert!(
1292 text_chunk_count > 1,
1293 "filler well over the holdback window must have produced at least one \
1294 progressive flush before the terminal-triggered final flush, got \
1295 {text_chunk_count} text chunk(s)"
1296 );
1297 let combined = collect_forwarded_text(&seen);
1298 assert!(
1299 !combined.contains(secret_value),
1300 "secret split across the streaming boundary must still be masked: {combined}"
1301 );
1302 }
1303
1304 #[tokio::test(start_paused = true)]
1305 async fn pem_block_split_across_chunk_boundary_is_still_fully_masked() {
1306 let task_id: Arc<str> = Arc::from("task-pem-chunked");
1314 let def_name: Arc<str> = Arc::from("agent-pem-chunked");
1315 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
1316 let buffer = new_buffer();
1317
1318 let body = "X".repeat(300);
1319 sender.send_text("intro text before the key ");
1320 sender.send_text(&format!("-----BEGIN RSA PRIVATE KEY-----\n{body}"));
1321 sender.send_text("\n-----END RSA PRIVATE KEY-----\nfollowing text after the key");
1322 sender.send_terminal(SubAgentState::Completed);
1323 drop(sender);
1324
1325 let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
1326 let collected = Arc::clone(&seen);
1327 run_forward_drain_with(
1328 task_id,
1329 def_name,
1330 rx,
1331 layers(),
1332 ForwardSurfaces {
1333 tui: true,
1334 bare: false,
1335 },
1336 buffer,
1337 move |chunk, surfaces, buffer| {
1338 collected.lock().unwrap().push(chunk.clone());
1339 dispatch_chunk(chunk, surfaces, buffer);
1340 },
1341 )
1342 .await;
1343
1344 let combined = collect_forwarded_text(&seen.lock().unwrap());
1345 assert!(
1346 !combined.contains(&body),
1347 "PEM body must not survive split across a chunk boundary: {combined}"
1348 );
1349 assert!(
1350 !combined.contains('X'),
1351 "no raw PEM body fragment may leak through an isolated flush of a middle slice \
1352 that itself contains neither BEGIN nor END: {combined}"
1353 );
1354 assert!(
1355 combined.contains("[REDACTED_PEM_KEY]"),
1356 "PEM placeholder must be present in the combined forwarded text: {combined}"
1357 );
1358 assert!(
1359 combined.contains("intro text before the key"),
1360 "text preceding the PEM block must still be forwarded: {combined}"
1361 );
1362 assert!(
1363 combined.contains("following text after the key"),
1364 "text following the PEM block must still be forwarded: {combined}"
1365 );
1366 }
1367
1368 #[tokio::test(start_paused = true)]
1369 async fn complete_key_immediately_followed_by_different_pem_armor_leaks_nothing() {
1370 let task_id: Arc<str> = Arc::from("task-pem-bundle");
1379 let def_name: Arc<str> = Arc::from("agent-pem-bundle");
1380 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
1381 let buffer = new_buffer();
1382
1383 let key_body = "Z".repeat(400);
1386 sender.send_text(&format!(
1387 "-----BEGIN RSA PRIVATE KEY-----\n{key_body}\n-----END RSA PRIVATE KEY-----\n\
1388 -----BEGIN CERTIFICATE-----\nMIIBcertbody"
1389 ));
1390 sender.send_text("\n-----END CERTIFICATE-----\nbundle complete");
1391 sender.send_terminal(SubAgentState::Completed);
1392 drop(sender);
1393
1394 let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
1395 let collected = Arc::clone(&seen);
1396 run_forward_drain_with(
1397 task_id,
1398 def_name,
1399 rx,
1400 layers(),
1401 ForwardSurfaces {
1402 tui: true,
1403 bare: false,
1404 },
1405 buffer,
1406 move |chunk, surfaces, buffer| {
1407 collected.lock().unwrap().push(chunk.clone());
1408 dispatch_chunk(chunk, surfaces, buffer);
1409 },
1410 )
1411 .await;
1412
1413 let combined = collect_forwarded_text(&seen.lock().unwrap());
1414 assert!(
1415 !combined.contains('Z'),
1416 "no fragment of the RSA key body may leak when immediately followed by a \
1417 different PEM armor type in the same delta: {combined}"
1418 );
1419 assert!(
1420 combined.contains("[REDACTED_PEM_KEY]"),
1421 "PEM placeholder must be present for the private key: {combined}"
1422 );
1423 assert!(
1424 combined.contains("bundle complete"),
1425 "text following the bundle must still be forwarded: {combined}"
1426 );
1427 }
1430
1431 #[tokio::test(start_paused = true)]
1432 async fn complete_key_followed_by_bare_trailing_begin_leaks_nothing() {
1433 let task_id: Arc<str> = Arc::from("task-pem-trailing-begin");
1437 let def_name: Arc<str> = Arc::from("agent-pem-trailing-begin");
1438 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
1439 let buffer = new_buffer();
1440
1441 let key_body = "Z".repeat(400);
1442 sender.send_text(&format!(
1443 "-----BEGIN RSA PRIVATE KEY-----\n{key_body}\n-----END RSA PRIVATE KEY-----\n-----BEGIN"
1444 ));
1445 sender.send_text(" EC PRIVATE KEY-----\nsecondbody\n-----END EC PRIVATE KEY-----\ndone");
1446 sender.send_terminal(SubAgentState::Completed);
1447 drop(sender);
1448
1449 let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
1450 let collected = Arc::clone(&seen);
1451 run_forward_drain_with(
1452 task_id,
1453 def_name,
1454 rx,
1455 layers(),
1456 ForwardSurfaces {
1457 tui: true,
1458 bare: false,
1459 },
1460 buffer,
1461 move |chunk, surfaces, buffer| {
1462 collected.lock().unwrap().push(chunk.clone());
1463 dispatch_chunk(chunk, surfaces, buffer);
1464 },
1465 )
1466 .await;
1467
1468 let combined = collect_forwarded_text(&seen.lock().unwrap());
1469 assert!(
1470 !combined.contains('Z'),
1471 "no fragment of the first key body may leak when a bare trailing -----BEGIN \
1472 follows it in the same buffer: {combined}"
1473 );
1474 assert!(
1475 !combined.contains("secondbody"),
1476 "no fragment of the second key body may leak either: {combined}"
1477 );
1478 assert_eq!(
1479 combined.matches("[REDACTED_PEM_KEY]").count(),
1480 2,
1481 "both blocks must be redacted independently: {combined}"
1482 );
1483 assert!(
1484 combined.contains("done"),
1485 "trailing text must survive: {combined}"
1486 );
1487 }
1488
1489 #[tokio::test(start_paused = true)]
1490 async fn pem_holdback_boundary_computation_does_not_panic_on_multibyte_text() {
1491 let task_id: Arc<str> = Arc::from("task-pem-multibyte");
1500 let def_name: Arc<str> = Arc::from("agent-pem-multibyte");
1501 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
1502 let buffer = new_buffer();
1503
1504 let cjk_body: String = std::iter::repeat_n('中', 300).collect();
1505 sender.send_text(&format!("-----BEGIN RSA PRIVATE KEY-----\n{cjk_body}"));
1506 sender.send_text("\n-----END RSA PRIVATE KEY-----\ndone");
1507 sender.send_terminal(SubAgentState::Completed);
1508 drop(sender);
1509
1510 let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
1511 let collected = Arc::clone(&seen);
1512 run_forward_drain_with(
1515 task_id,
1516 def_name,
1517 rx,
1518 layers(),
1519 ForwardSurfaces {
1520 tui: true,
1521 bare: false,
1522 },
1523 buffer,
1524 move |chunk, surfaces, buffer| {
1525 collected.lock().unwrap().push(chunk.clone());
1526 dispatch_chunk(chunk, surfaces, buffer);
1527 },
1528 )
1529 .await;
1530
1531 let combined = collect_forwarded_text(&seen.lock().unwrap());
1532 assert!(
1533 !combined.contains('中'),
1534 "CJK key body must not leak: {combined}"
1535 );
1536 assert!(combined.contains("[REDACTED_PEM_KEY]"));
1537 assert!(
1538 combined.contains("done"),
1539 "trailing text must survive: {combined}"
1540 );
1541 }
1542
1543 #[tokio::test(start_paused = true)]
1544 async fn buffer_entry_survives_during_grace_window_then_evicted() {
1545 let task_id: Arc<str> = Arc::from("task-grace");
1549 let def_name: Arc<str> = Arc::from("agent-grace");
1550 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
1551 let buffer = new_buffer();
1552
1553 sender.send_text("visible during the grace window");
1554 sender.send_terminal(SubAgentState::Completed);
1555 drop(sender);
1556
1557 let drain_buffer = Arc::clone(&buffer);
1558 let drain_task_id = Arc::clone(&task_id);
1559 let handle = tokio::spawn(run_forward_drain(
1560 drain_task_id,
1561 def_name,
1562 rx,
1563 layers(),
1564 ForwardSurfaces {
1565 tui: true,
1566 bare: false,
1567 },
1568 drain_buffer,
1569 ));
1570
1571 tokio::time::advance(Duration::from_millis(1)).await;
1573 tokio::task::yield_now().await;
1574
1575 let mid_window_tail = forwarded_tail(&buffer, &task_id, 10);
1576 assert_eq!(
1577 mid_window_tail.len(),
1578 1,
1579 "exactly one forwarded line expected"
1580 );
1581 assert!(
1582 mid_window_tail[0].contains("visible during the grace window"),
1583 "the transcript must still be visible during the grace window, got: {:?}",
1584 mid_window_tail[0]
1585 );
1586
1587 tokio::time::advance(FORWARD_BUFFER_GRACE + Duration::from_millis(1)).await;
1588 handle.await.expect("drain task must not panic");
1589
1590 let post_eviction_tail = forwarded_tail(&buffer, &task_id, 10);
1591 assert!(
1592 post_eviction_tail.is_empty(),
1593 "buffer entry must be evicted once the grace window elapses"
1594 );
1595 }
1596
1597 #[test]
1598 fn empty_text_is_not_sent() {
1599 let task_id: Arc<str> = Arc::from("task-4");
1600 let def_name: Arc<str> = Arc::from("agent-4");
1601 let (sender, mut rx) = new_channel(task_id, def_name);
1602 sender.send_text("");
1603 sender.send_thinking("");
1604 drop(sender);
1605 assert!(
1606 rx.try_recv().is_err(),
1607 "empty text/thinking must not be sent onto the ingress channel"
1608 );
1609 }
1610
1611 #[test]
1612 fn channel_full_increments_drop_counter_and_does_not_panic() {
1613 let task_id: Arc<str> = Arc::from("task-5");
1614 let def_name: Arc<str> = Arc::from("agent-5");
1615 let (sender, mut rx) = new_channel(task_id, def_name);
1616 for i in 0..FORWARD_CHANNEL_CAPACITY + 10 {
1617 sender.send_text(&format!("chunk {i}"));
1618 }
1619 let mut received = 0;
1621 while rx.try_recv().is_ok() {
1622 received += 1;
1623 }
1624 assert!(
1625 received > 0,
1626 "at least some chunks must have been delivered"
1627 );
1628 assert!(
1629 received <= FORWARD_CHANNEL_CAPACITY,
1630 "received must never exceed channel capacity"
1631 );
1632 }
1633
1634 #[test]
1635 fn forward_surfaces_any() {
1636 assert!(!ForwardSurfaces::default().any());
1637 assert!(
1638 ForwardSurfaces {
1639 tui: true,
1640 bare: false
1641 }
1642 .any()
1643 );
1644 assert!(
1645 ForwardSurfaces {
1646 tui: false,
1647 bare: true
1648 }
1649 .any()
1650 );
1651 }
1652}