Skip to main content

zeph_subagent/
forward.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Live subagent transcript forwarding (issue #6359, spec `068-subagent-transcript-forward`;
5//! token-level intra-turn streaming, issue #6456, FR-002b).
6//!
7//! Opt-in forwarding of a running subagent's text/thinking output to the TUI runtime detail
8//! view and/or a `--bare` stdout sink, under the single `forward_transcript` config flag.
9//! Granularity depends on provider support: when the provider's native streaming-with-tools
10//! path is available (`agent_loop.rs` drives it), text/thinking chunks are forwarded as
11//! partial deltas *within* a turn; otherwise (or when streaming fails) the full, untruncated
12//! text/thinking output of one completed LLM turn is forwarded once the turn completes
13//! (FR-002a, unchanged). Pipeline shape:
14//!
15//! ```text
16//! agent_loop.rs (sync, non-blocking) --try_send(RawChunk)--> per-task mpsc (cap 128)
17//!     -> manager-owned per-task drain: sanitize (the ONE sanitize point) -> dispatch to sinks
18//! ```
19//!
20//! `RawChunk` only ever travels on the ingress channel; `SanitizedChunk` is constructed
21//! exclusively by the drain's sanitize step and is the only type any sink can receive
22//! (NFR-005 enforced structurally, not by convention).
23//!
24//! # Design contract: deltas are ephemeral, display-only (FR-002b)
25//!
26//! Every chunk sent through `ForwardSender::send_text` / `ForwardSender::send_thinking` —
27//! whether it carries a whole turn's text or one streamed delta — travels on the same
28//! tail-drop `mpsc` and MUST be treated as **display-only**. A dropped chunk is a display
29//! gap, never a correctness error: the loop's own accumulated response text (returned from
30//! `run_agent_loop`'s LLM call and pushed into `messages`) is assembled independently of
31//! whether any given delta was actually forwarded, and the guaranteed terminal chunk (see
32//! `ForwardSender::send_terminal`) marks the one point a consumer may treat as authoritative
33//! for "this run reached a terminal state". No consumer (TUI ring buffer, `--bare` sink, a
34//! future sink) may reconstruct the subagent's conversational state — let alone feed it back
35//! into the parent's LLM context — by concatenating forwarded chunks; deltas never enter any
36//! LLM context, they exist purely for live human-facing display.
37
38use 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
51/// Bound on the per-task ingress channel (mpsc). `try_send` drops the newest chunk on
52/// full (tail-drop) rather than blocking the subagent's own turn loop (NFR-001).
53const FORWARD_CHANNEL_CAPACITY: usize = 128;
54
55/// Maximum number of sanitized display lines retained per task in the TUI ring buffer.
56const FORWARD_RING_CAPACITY: usize = 200;
57
58/// How long a finished task's ring buffer entry survives after its terminal chunk, so a
59/// TUI detail view opened just after completion still shows the final transcript.
60const FORWARD_BUFFER_GRACE: Duration = Duration::from_secs(5);
61
62/// Which consumer surfaces are active for this session, fixed at session start (session
63/// scope, not hot-swappable — a headless run does not gain a TUI mid-session).
64///
65/// Set once via [`crate::SubAgentManager::set_forward_surfaces`] during bootstrap. When both
66/// fields are `false`, no forwarding sender or drain is ever constructed for any subagent,
67/// regardless of `forward_transcript` config (FR-007).
68#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
69pub struct ForwardSurfaces {
70    /// A TUI session is active — sanitized chunks are appended to the per-task ring buffer.
71    pub tui: bool,
72    /// `--bare` mode is active — sanitized chunks are written as JSON lines to stdout.
73    pub bare: bool,
74}
75
76impl ForwardSurfaces {
77    /// Returns `true` when at least one consumer surface is active.
78    #[must_use]
79    pub fn any(self) -> bool {
80        self.tui || self.bare
81    }
82}
83
84/// One incremental piece of a subagent's forwarded output, pre-sanitize.
85///
86/// Only ever travels on the per-task ingress `mpsc` — never exposed outside this module.
87#[derive(Debug, Clone)]
88pub(crate) struct RawChunk {
89    kind: ForwardChunkKind,
90}
91
92/// The content carried by a forwarded chunk. `pub(crate)`: only ever constructed by
93/// `ForwardSender`'s `send_*` methods, never named outside this crate.
94#[derive(Debug, Clone)]
95#[non_exhaustive]
96pub(crate) enum ForwardChunkKind {
97    /// Full, untruncated text produced by one completed LLM turn (FR-002a).
98    Text(String),
99    /// Full, untruncated visible reasoning text from one thinking block.
100    Thinking(String),
101    /// End-of-transcript signal (FR-008): either the loop's own terminal status, or a
102    /// synthesized backstop when the ingress channel closed without one (hard abort).
103    Terminal(SubAgentState),
104}
105
106/// A forwarded chunk after passing through the drain's single sanitize stage.
107///
108/// Constructed only by the drain's internal sanitize step — the sole type any sink (TUI
109/// ring, `--bare` stdout, a future network sink) can receive, so a sink author cannot
110/// physically emit unsanitized content (NFR-005). `pub(crate)` (not `pub`, security review
111/// Finding 2): nothing outside this crate needs this type — `SubAgentManager::forwarded_tail`
112/// exposes already-rendered `String` lines instead — so it is not part of the public API
113/// surface a future sink integration could hand-construct from.
114#[derive(Debug, Clone)]
115pub(crate) struct SanitizedChunk {
116    /// Task ID of the originating subagent.
117    pub(crate) task_id: Arc<str>,
118    /// Subagent definition name.
119    pub(crate) def_name: Arc<str>,
120    /// Monotonic per-task sequence number (FR-003).
121    pub(crate) seq: u64,
122    /// The sanitized content.
123    pub(crate) kind: SanitizedChunkKind,
124}
125
126/// Sanitized variant of [`ForwardChunkKind`].
127#[derive(Debug, Clone)]
128#[non_exhaustive]
129pub(crate) enum SanitizedChunkKind {
130    /// Sanitized text output.
131    Text(String),
132    /// Sanitized thinking output.
133    Thinking(String),
134    /// End-of-transcript signal, carried through unchanged (no text to sanitize).
135    Terminal(SubAgentState),
136}
137
138/// The full sanitization pipeline applied at the drain's single sanitize point (NFR-005).
139///
140/// Bundles the baseline injection/truncation pass (`ContentSanitizer`, always present), an
141/// always-on generic secret-*shape* scrub (`scrub_secret_shapes`, #6571 — catches API-key-
142/// shaped strings a subagent fabricates or echoes, not just registered vault values), and two
143/// optional hardening layers that mirror the ones already guarding the analogous sub-agent-
144/// output *egress* path (debug dumps, see `PiiScrubbingDumpSink` / #6407 and
145/// `apply_secret_masking` / #5437): a [`SecretMaskRegistry`] that replaces known vault
146/// secrets with opaque placeholders, and a [`PiiFilter`] that scrubs emails/phones/SSNs/etc.
147/// The latter two are `None` unless explicitly wired via `SubAgentManager::set_secret_registry`
148/// / `set_pii_filter` — forwarding remains fully functional (baseline + shape sanitization
149/// only) when neither is configured, matching this crate's existing opt-in-hardening
150/// conventions.
151pub(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    // Exact-value registry masking runs first so a registered vault secret gets its typed
161    // `<SECRET:category:...>` placeholder; the shape-based scrub below then only catches
162    // whatever the registry didn't know about (e.g. a key a subagent fabricates or echoes).
163    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
173/// Bounded lookback window (bytes) held back from the tail of a pending `Text`/`Thinking`
174/// buffer before sanitizing and emitting its safe prefix (review Critical Issue #2, #6456
175/// follow-up).
176///
177/// Without this, each streamed delta (FR-002b) was sanitized in complete isolation — a
178/// secret or PII pattern split across two `ToolSseEvent` chunk boundaries matched neither
179/// fragment individually and reached `--bare` stdout / the TUI ring buffer unmasked. Holding
180/// back this many trailing bytes on every partial flush guarantees any pattern whose two
181/// halves arrive within this window of each other is always sanitized as one contiguous
182/// string before being released.
183///
184/// Chosen generously above [`crate::grants::GrantedSecret`]-delivered or vault-registered
185/// secret lengths seen in practice and every PII pattern in `zeph_sanitizer::pii` (email/
186/// phone/SSN/credit-card are all well under 80 bytes). A secret whose split fragments are
187/// separated by *more* than this many bytes of other already-flushed content is a residual
188/// limitation inherent to any bounded-window approach — not eliminated, only made
189/// practically unreachable for realistic secret/PII lengths.
190const SANITIZE_HOLDBACK_BYTES: usize = 256;
191
192/// Cap, in bytes, on how far a progressive flush ([`split_off_safe_prefix`] with a non-zero
193/// `holdback`) will widen its holdback to keep an unterminated PEM/SSH2 private-key header
194/// (see `zeph_common::secrets::PEM_PRIVATE_KEY_UNTERMINATED_PATTERN`) fully inside the pending
195/// buffer, so the eventual flush is still covered end-to-end by that fallback pattern's own
196/// `PEM_BODY_CAP` bound (currently 8,192 characters — kept equal here so a force-flushed
197/// chunk is never larger than what the fallback pattern can redact in one match).
198///
199/// Without this cap, a subagent that never closes a `-----BEGIN ... PRIVATE KEY-----` block
200/// (adversarially, or because the footer chunk was tail-dropped by the bounded ingress
201/// channel) would force this buffer to grow without bound, since the "hold back to the
202/// header's start" rule below would otherwise apply for the rest of the task's lifetime.
203///
204/// Known low-priority UX gap (#6592 follow-up, "M6", not fixed in this pass): while a header
205/// is held back, up to this many bytes of a subagent's *legitimate* remaining output can sit
206/// unflushed with no visible progress in the live transcript (TUI detail view / `--bare`
207/// stdout) until either the footer arrives or the terminal flush releases it — delayed, never
208/// dropped, so no data is lost, but a short remaining answer can appear frozen for a moment.
209/// No status indicator (per CLAUDE.md's TUI background-status rule) currently distinguishes
210/// this from a genuine stall. Left undone here since it is UI/status plumbing rather than a
211/// redaction-correctness fix; worth a small follow-up if it proves noticeable in practice.
212const PEM_HOLDBACK_CAP_BYTES: usize = zeph_common::secrets::PEM_BODY_CAP;
213
214/// Per-task raw text accumulated but not yet sanitized/emitted (review Critical Issue #2).
215///
216/// Kept separate for the `Text` and `Thinking` streams since they are independent logical
217/// channels that must never be concatenated with each other.
218#[derive(Default)]
219struct PendingSanitizeBuffers {
220    text: String,
221    thinking: String,
222}
223
224/// Byte offset in `buf` of the last PEM/SSH2 header marker (`-----BEGIN` or `---- BEGIN`)
225/// starting strictly before `before`, if any.
226fn 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
234/// Byte offset just past the first PEM/SSH2 footer marker's `END` token (`-----END` or
235/// `---- END`, both exactly 8 bytes) found anywhere in `buf` at or after `marker_idx`, if any.
236fn 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
245/// Compute the safe progressive-flush boundary for `buf`, starting from the flat-holdback
246/// `natural_target` (critic C1/C1-R: closes the gap where a PEM block split across streamed
247/// deltas would otherwise reach a sink as two or more separately-sanitized fragments, none of
248/// which contains the whole header-to-footer span).
249///
250/// A first version of this function only ever inspected the *last* header marker in the whole
251/// buffer via `rfind`. That is unsound: pulling the cut back to that marker's start can land
252/// it in the middle of an **earlier**, already-complete block — the flushed prefix carries
253/// that earlier block's header (so a fallback pattern redacts *something*), but what remains
254/// in the buffer is a headerless middle fragment of key body that no pattern can ever match on
255/// any later flush. Concretely, a complete key block immediately followed by a *different* PEM
256/// armor type in the same delta (e.g. `-----BEGIN RSA PRIVATE KEY-----`...`-----END RSA
257/// PRIVATE KEY-----` followed by `-----BEGIN CERTIFICATE-----`, an ordinary key+cert bundle —
258/// not an adversarial construction) reproduced this: `rfind` finds the `CERTIFICATE` header,
259/// classifies it as unterminated, and pulls the cut back into the *first* block's body.
260///
261/// This version instead walks backward from `natural_target`: find the last header marker
262/// starting before the candidate cut; if it has a footer whose end lies at or before the
263/// candidate, the candidate is safe as-is. Otherwise (no footer anywhere, or a footer that
264/// ends *after* the candidate) the candidate cannot be trusted — pull it back to that marker's
265/// own start and repeat, so an earlier header found on the next iteration is validated against
266/// the *new*, smaller candidate rather than being skipped. The candidate strictly decreases
267/// each iteration a header is found, so this always terminates. Only once a marker turns out
268/// to have **no footer anywhere in `buf`** is [`PEM_HOLDBACK_CAP_BYTES`] applied, forcing a
269/// partial flush up to `buf.len() - PEM_HOLDBACK_CAP_BYTES` if that is further forward than
270/// the marker itself — so a header that never closes (adversarial, or a dropped footer chunk)
271/// cannot force unbounded buffering, while a header that *does* close later (just further away
272/// than the cap) is never force-flushed mid-body, deferring instead to a future call once its
273/// footer is within reach (see the `PEM_BODY_CAP` doc comment in `zeph_common::secrets` for the
274/// accepted tradeoff when even that eventual span exceeds the cap).
275fn 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
292/// Split off `buf`'s sanitizable prefix, leaving the last `holdback` bytes (rounded down to
293/// the nearest UTF-8 char boundary, same class of problem as UTF-8 chunk-boundary handling)
294/// in place for a future call to potentially combine with. Pass `holdback = 0` to flush the
295/// entire remaining buffer — used once no more data for this task is coming (an explicit
296/// `Terminal` chunk or the hard-abort backstop), so buffered content is only ever delayed,
297/// never silently dropped.
298///
299/// When `holdback` is non-zero (a progressive, non-terminal flush), the flush boundary is
300/// adjusted by [`pem_safe_flush_target`] around any PEM/SSH2 header marker(s) in `buf` so a
301/// flat byte-count holdback alone can never split a PEM block's `BEGIN` and `END` markers
302/// across two separate `sanitize_text` calls, each seeing only a fragment and none matching
303/// the full-body PEM pattern as a unit.
304///
305/// A final flush (`holdback == 0`) always flushes everything regardless, since nothing more
306/// is coming for this task; any still-unterminated header at that point is handled by
307/// `PEM_PRIVATE_KEY_UNTERMINATED_PATTERN`'s own fallback redaction once sanitized.
308///
309/// Returns `None` when there is nothing new to emit yet.
310fn 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        // C3 (#6592 follow-up): `pem_safe_flush_target` and its helpers slice `buf` directly
318        // (`&buf[..before]`, `&buf[marker_idx..]`) before this function's own
319        // `floor_char_boundary` call below ever runs, so the candidate passed in must already
320        // sit on a UTF-8 char boundary — a raw `buf.len() - holdback` byte offset can land
321        // mid-codepoint on multibyte input (CJK, emoji, accented text) and panic. Every offset
322        // used for further slicing within `pem_safe_flush_target` (marker/footer positions
323        // from `rfind`/`find` on ASCII-only marker literals) is inherently boundary-aligned,
324        // so aligning only this entry value is sufficient. The one exception — `capped` in
325        // the unterminated-header branch, a raw arithmetic offset — is never itself used to
326        // slice `buf` again; it is only returned and re-aligned by this function's own
327        // `floor_char_boundary` call below.
328        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
340/// Attempt to flush a pending buffer's safe prefix, sanitize it, and wrap the result via
341/// `wrap_kind` (`SanitizedChunkKind::Text` or `::Thinking`, both valid as a
342/// `fn(String) -> SanitizedChunkKind` since each is a single-field tuple variant). Returns
343/// `None` when [`split_off_safe_prefix`] found nothing new to emit yet.
344fn 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/// Flush both pending buffers in full (no holdback — nothing more is coming for this task)
370/// and dispatch any resulting chunk(s). Called immediately before an explicit `Terminal`
371/// chunk or the hard-abort backstop, so buffered content is only ever delayed until the
372/// run's very end, never silently dropped.
373#[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
414/// Sender-side handle held by a single subagent's own turn loop for the lifetime of its
415/// run only.
416///
417/// Deliberately **not** `Clone`: the drain's hard-abort backstop (see [`run_forward_drain`])
418/// relies on this being the sole `mpsc::Sender` for its task — dropping the loop's future
419/// must be the only way the channel closes. Do not store this (or its inner `Sender`) in
420/// any struct that outlives a single subagent run (`SpawnContext`, a resume/retry retainer,
421/// etc.) — see P-new-3 in the implementation handoff.
422pub(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    /// Forward a piece of assistant text output. Call only from behind an
464    /// `if let Some(f) = forward` guard — the caller (`agent_loop.rs`) must never construct
465    /// or clone the text ahead of that guard (FR-007).
466    ///
467    /// `text` may be a whole turn's full, untruncated text (FR-002a, the non-streaming or
468    /// stream-fallback path) or one incremental delta from a native streaming response
469    /// (FR-002b) — both are display-only chunks tail-dropped under backpressure identically;
470    /// see the module-level "Design contract" section. Callers must not send both the
471    /// streamed deltas and the final whole-turn text for the same turn — that would double-
472    /// forward the same content (see `agent_loop.rs::call_provider_with_status`'s `streamed`
473    /// flag).
474    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    /// Forward a piece of visible thinking output — a whole completed thinking block
482    /// (FR-002a) or one incremental thinking delta (FR-002b). Same no-op-behind-`Some`
483    /// contract and no-double-forward caller responsibility as [`send_text`][Self::send_text].
484    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    /// Emit the terminal (end-of-transcript) chunk. Co-located with every site that
492    /// publishes a terminal `SubAgentStatus` on the status channel (FR-008).
493    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
501/// Render a sanitized chunk as a single display line for the TUI ring buffer, or `None`
502/// for chunks that carry no display text (terminal events).
503fn 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
521/// Write one `--bare` stdout event as a single JSON line (M6: one `println!` per chunk,
522/// never multi-write — `println!` takes Rust's internal stdout lock per call, so this is
523/// line-atomic even when interleaved with the main output path).
524fn 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
555/// Dispatch one sanitized chunk to every active surface.
556fn 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
574/// Build a fresh `mpsc` ingress pair and its sender-side handle for one subagent run.
575pub(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
583/// Manager-owned per-task drain: the single sanitize stage plus sink dispatch, running for
584/// the lifetime of one subagent's forwarding channel.
585///
586/// # Terminal detection (critic C-new-1, must-fix)
587///
588/// The loop breaks immediately after dispatching **any** explicit terminal chunk (sent by
589/// `agent_loop.rs` at each of its three terminal-status sites). This is the only way to
590/// avoid double-emitting a terminal on the happy path: on normal completion the loop sends
591/// an explicit `Terminal` and then drops its `Sender`; because the `Some(raw)` arm below
592/// breaks unconditionally on a terminal chunk, `recv()` is never called again afterward, so
593/// the `None` arm can never fire once an explicit terminal has already been handled.
594/// Consequently, reaching the `None` arm at all — the channel closed with no message
595/// pending — is *only* possible when no explicit terminal was ever sent, i.e. the genuine
596/// hard-abort backstop (`JoinHandle::abort()` / cancel-token firing mid-`.await` drops the
597/// loop's future, and with it its sole `Sender`, before any terminal-status site runs): it
598/// unconditionally synthesizes `Terminal(Canceled)`.
599///
600/// After the loop ends, the task's ring buffer entry is evicted following a short grace
601/// window so a TUI detail view opened just after completion still shows the final
602/// transcript (S3: bounds `forward_buffer` growth across a long multi-subagent session).
603pub(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
623/// Same as [`run_forward_drain`], parameterized over the dispatch step so tests can observe
624/// exactly how many (and which) [`SanitizedChunk`]s the drain hands to the sinks — including
625/// `Terminal` chunks, which [`dispatch_chunk`] itself never writes to the TUI ring buffer
626/// (`display_line` returns `None` for them) and which the eviction sweep runs unconditionally
627/// after either loop exit, so buffer *contents* alone cannot distinguish "exactly one terminal
628/// dispatched" from "two". Production always calls this via [`run_forward_drain`] with
629/// [`dispatch_chunk`] itself as the dispatch step — behavior is unchanged.
630async 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
734/// Read the current ring-buffer tail for `task_id` (up to the last `n` lines).
735///
736/// Returns an empty vector for a task with no forwarded lines yet (or forwarding inactive).
737pub(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
746/// Construct a fresh, empty forwarding ring buffer.
747pub(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    /// Runs the drain via [`run_forward_drain_with`], counting how many `Terminal` chunks
768    /// were actually handed to the dispatch step — the direct, discriminating observable for
769    /// critic C-new-1 (a regression that re-introduces the double-terminal bug increments this
770    /// to 2; buffer state and hang/panic-absence cannot tell the two implementations apart,
771    /// since `dispatch_chunk` never writes `Terminal` chunks to the ring buffer and the
772    /// post-loop eviction runs exactly once regardless of how many terminals were dispatched
773    /// beforehand).
774    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        // Regression guard for critic C-new-1: an explicit Terminal followed by Sender drop
804        // must produce exactly one terminal dispatch, not two. Asserts on the actual dispatch
805        // count (see `run_and_count_terminals`), not on buffer state — a Terminal chunk is
806        // never written to the ring buffer, so buffer-only assertions cannot detect this
807        // regression (confirmed by the testing validator).
808        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); // simulate abort: no explicit terminal was ever sent
850
851        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        // NFR-005 / security Finding 1: forwarded content containing a registered vault
894        // secret must come out masked, not verbatim.
895        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        // A value that is BOTH registered with the SecretMaskRegistry AND shape-matched by
955        // `scrub_secret_shapes` (e.g. any `sk-...` value, since `SecretMaskRegistry::register`
956        // is commonly used for real API keys) must come out through the pipeline with the
957        // registry's typed `<SECRET:category:...>` placeholder, not the shape scrub's generic
958        // `[REDACTED]` marker — proving registry masking really does run before the shape scrub
959        // (see the ordering comment on `sanitize_text`) and the shape scrub does not re-process
960        // (double-mask) the registry's own placeholder output.
961        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        // #6571: a subagent that fabricates or echoes an API-key-shaped string in its own
1021        // response text must have it masked even though it was never registered with a
1022        // SecretMaskRegistry (no vault-loaded secret ever equals this value) — the always-on
1023        // shape-based scrub (`scrub_secret_shapes`) is the only layer that can catch this.
1024        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        // NFR-005 / security Finding 1: forwarded content containing PII-shaped text must be
1066        // scrubbed when a PiiFilter layer is configured.
1067        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    // --- Review Critical Issue #2: cross-delta secret/PII masking gap ---
1115
1116    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        // A secret whose bytes are split across two separate `send_text` calls — simulating
1129        // two ToolSseEvent::ContentChunk deltas arriving back-to-back during FR-002b
1130        // streaming — must still be masked once both fragments have been buffered. Neither
1131        // fragment alone contains the full registered secret value, so per-delta-isolated
1132        // sanitization (the pre-fix behavior) would have let it straight through.
1133        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        // Same cross-delta gap, PII side: an email address split across two `send_text`
1188        // calls must still be scrubbed once both fragments are buffered together.
1189        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        // Stronger test of the holdback *window* itself (not just "buffer until terminal"):
1236        // enough filler precedes the secret's two fragments to force at least one
1237        // progressive flush mid-stream (SANITIZE_HOLDBACK_BYTES is well under the total
1238        // filler size), proving flushing genuinely happens before the terminal event, yet
1239        // the secret's fragments — arriving back-to-back right after the filler — must still
1240        // land inside the held-back tail and be masked as one contiguous string once
1241        // fully buffered.
1242        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        // Critic C1: a PEM block whose header+body arrive in one delta and whose footer
1307        // arrives in a later delta must not have its header sanitized in isolation (splitting
1308        // it from the body/footer, and — because the fixed-256-byte flat holdback alone would
1309        // let a middle fragment with neither BEGIN nor END pass through completely
1310        // unredacted). The header+body chunk here (~330 bytes) deliberately exceeds
1311        // SANITIZE_HOLDBACK_BYTES so a flat holdback alone would have force-flushed part of
1312        // the still-open block before the footer chunk arrives.
1313        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        // Critic C1-R: a complete, already-closed key block immediately followed by a
1371        // *different* PEM armor type's header (e.g. a certificate) in the same delta — an
1372        // ordinary key+cert bundle, not an adversarial construction. The first fix for C1
1373        // only ever inspected the *last* header marker via `rfind`, found the CERTIFICATE
1374        // header unterminated, and pulled the cut back into the middle of the already-closed
1375        // RSA key's body, leaking a headerless middle fragment that no pattern could later
1376        // match. `pem_safe_flush_target` must walk backward and validate the RSA block
1377        // separately from the trailing CERTIFICATE header.
1378        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        // Filler character 'Z' deliberately chosen not to collide with any surrounding literal
1384        // text (placeholders, marker labels) so a leak is unambiguous in the assertions below.
1385        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        // The certificate itself is public material, not a secret — it is not expected to be
1428        // redacted by the private-key patterns (only that the *key* leaked nothing above).
1429    }
1430
1431    #[tokio::test(start_paused = true)]
1432    async fn complete_key_followed_by_bare_trailing_begin_leaks_nothing() {
1433        // Critic C1-R, second reproduction: a complete key block followed by a bare trailing
1434        // `-----BEGIN` (no label, no body yet — e.g. the very start of the next streamed
1435        // delta) in the same buffer. Must not leak any of the first block's body either.
1436        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        // Critic C3: `pem_safe_flush_target`'s helpers slice the buffer using the raw
1492        // `buf.len() - holdback` byte offset, computed *before* any UTF-8 char-boundary
1493        // alignment — landing mid-codepoint on CJK/emoji/accented text panics
1494        // ("byte index N is not a char boundary; it is inside '中'"), killing the drain task
1495        // and losing the whole pending buffer. This body (300 repeats of a 3-byte CJK
1496        // character, no footer in the first delta) is sized so the natural pre-header
1497        // holdback cut (`buf.len() - SANITIZE_HOLDBACK_BYTES`) lands inside the CJK run, not
1498        // on a character boundary — the exact shape the critic's sweep used to reproduce it.
1499        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        // Must not panic (the actual regression under test) — a panic here aborts the drain
1513        // task and silently stops forwarding for the rest of the subagent's run.
1514        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        // S3: the grace window's entire purpose is that a TUI view opened just after
1546        // completion still sees the transcript — verify the mid-window state directly with
1547        // controlled virtual-time stepping, not just the post-eviction end state.
1548        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        // Let the drain process both chunks and enter its grace-window sleep.
1572        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        // Drain a few to prove the channel still functions after overflow.
1620        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}