Skip to main content

devflow_core/
canary.rs

1//! The delivery canary (D-13): the guard that notices when the undocumented
2//! CLI behaviour this whole arc rests on has gone away.
3//!
4//! # Why a planted token rather than a version check
5//!
6//! Claude Code's `task-notification` delivery — the CLI waking a live session
7//! back up after a background task finishes — is **undocumented behaviour**,
8//! observed only on `claude_code_version 2.1.220`. A CLI update can withdraw it
9//! without any announcement, and if it is withdrawn then every multi-plan wave
10//! silently orphans its dispatched work: exactly the 999.64 shape this phase
11//! exists to close. Reading the version string would guard a *proxy* for the
12//! behaviour, not the behaviour, and would go on reporting healthy the moment
13//! the same version number stopped meaning the same thing. So the guard plants
14//! a value only DevFlow knows and confirms it comes back.
15//!
16//! # What a `Confirmed` outcome does and does not mean
17//!
18//! It means **the notification path is alive**. It NEVER means the dispatched
19//! work happened. The agent can read the token out of its own prompt and emit
20//! it without doing anything at all — that is 999.67's shape, accepted here
21//! deliberately (threat T-31-11) rather than mitigated, because mitigating it
22//! needs per-child tokens and D-14 defers those on size. Summaries and merges
23//! remain the evidence of work (D-16/D-18). Nothing in this module may be
24//! rephrased to imply otherwise.
25//!
26//! # Where the trust decision is made
27//!
28//! Not here. The CLI echoes the operator's prompt back into the same stdout as
29//! a `user` event, so the planted token **will** appear in the capture whether
30//! or not anything was delivered — that echo is what produced the checkpoint
31//! false positive 30-05 had to fix. The question "did this token come back from
32//! somewhere trustworthy?" is therefore answered by exactly one function in
33//! this codebase, [`crate::agent_result::token_reported_in_capture`], which
34//! confines the match to events that are both `type: "result"` and
35//! orchestrator-authored. This module delegates to it and holds no notion of
36//! its own about which lines are trustworthy — a second such notion would be
37//! free to drift away from the first, and the drift would be invisible.
38
39use crate::agent_result;
40use crate::agents::{AgentAdapter, ClaudeAgent};
41use crate::git::hermetic_command;
42use crate::monitor::{self, CloseRule};
43use crate::phase_id::PhaseId;
44use serde::{Deserialize, Serialize};
45use std::io::{BufRead, BufReader, Write};
46use std::path::{Path, PathBuf};
47use std::process::Stdio;
48use std::sync::atomic::{AtomicU64, Ordering};
49use std::sync::mpsc;
50use std::time::{Duration, Instant};
51use tracing::warn;
52
53/// The fixed, greppable prefix every declared canary token carries.
54///
55/// Exposed so the run's provenance can record WHICH guard ran without
56/// recording the token itself (T-31-13).
57pub const TOKEN_PREFIX: &str = "DEVFLOW_DELIVERY_CANARY_";
58
59/// File name of the canary's own throwaway capture, inside the capture dir.
60///
61/// Deliberately NOT the phase capture (`.devflow/phase-NN-stdout.log`): that
62/// file is the one artifact the entire Layer 1 cascade decides a stage on, and
63/// a guard that clobbered it would break the thing it exists to protect.
64const CAPTURE_FILE: &str = "delivery-canary.jsonl";
65
66/// Monotonic within one process — the third input to [`declare_token`].
67static TOKEN_SEQ: AtomicU64 = AtomicU64::new(0);
68
69/// Declare a fresh success token for one canary run.
70///
71/// **This is a nonce, not a secret, and must not be "upgraded" into one.** The
72/// only property required (RESEARCH § ASVS V6) is that an agent cannot produce
73/// the value by chance inside its own generated text. A 64-bit hash of the
74/// current wall-clock nanos, this process's pid and a per-process counter
75/// clears that bar by a wide margin, and it costs no new dependency — which is
76/// why `std::hash::DefaultHasher` is used here rather than a CSPRNG crate.
77/// Nothing downstream authenticates anything with this value.
78///
79/// Two calls in one process differ because the counter feeds the hash. That
80/// makes distinctness overwhelming (a 64-bit collision), not absolute; the
81/// token is a nonce and nothing breaks on the ~2⁻⁶⁴ tie.
82pub fn declare_token() -> String {
83    use std::hash::{Hash, Hasher};
84
85    let nanos = std::time::SystemTime::now()
86        .duration_since(std::time::UNIX_EPOCH)
87        .map(|d| d.as_nanos())
88        .unwrap_or(0);
89    let seq = TOKEN_SEQ.fetch_add(1, Ordering::Relaxed);
90
91    let mut hasher = std::collections::hash_map::DefaultHasher::new();
92    nanos.hash(&mut hasher);
93    std::process::id().hash(&mut hasher);
94    seq.hash(&mut hasher);
95
96    format!("{TOKEN_PREFIX}{:016x}", hasher.finish())
97}
98
99/// What one canary run established.
100///
101/// `Absent` and `Unverified` are kept apart on purpose, and collapsing them
102/// would be a real loss of information: "the CLI ran and the behaviour is gone"
103/// and "the CLI could not be run at all" call for completely different operator
104/// action, and a merged variant would report a missing binary as a broken
105/// premise (threat T-31-12 — the risk this guard carries is a FALSE refusal).
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum CanaryOutcome {
109    /// The declared token came back from a trustworthy place. The notification
110    /// path is alive. This says NOTHING about whether work happened.
111    Confirmed,
112    /// The CLI ran and the token did not come back. The premise this arc rests
113    /// on is no longer backed by observed behaviour.
114    Absent,
115    /// The guard itself could not reach a conclusion — carries the reason. Not
116    /// a statement about the CLI's behaviour.
117    Unverified(String),
118}
119
120/// How the canary gets a child to talk to.
121///
122/// The seam exists so the matcher can be tested without spawning an agent: every
123/// test in this module injects a launcher that writes a canned capture, and none
124/// of them runs `claude`. `run` returns `Err` ONLY when the child could not be
125/// run to the point of producing a capture — a child that ran and said nothing
126/// useful is `Ok`, because that is a fact about the CLI's behaviour and belongs
127/// in the `Absent`/`Confirmed` decision rather than in the `Unverified` one.
128pub trait CanaryLauncher {
129    /// Run one throwaway agent turn against `prompt`, teeing its stdout to
130    /// `capture`.
131    fn run(&self, prompt: &str, capture: &Path) -> Result<(), String>;
132}
133
134/// Where one canary run's throwaway capture lands.
135pub fn canary_capture_path(capture_dir: &Path) -> PathBuf {
136    capture_dir.join(CAPTURE_FILE)
137}
138
139/// The throwaway prompt: dispatch one trivial background task, wait for its
140/// completion notification, and only then report.
141///
142/// The `DEVFLOW_RESULT:` line and the bare token line are separate on purpose.
143/// The marker line is what a pipe-owning supervisor's close rule watches for
144/// (it must parse as the existing marker grammar, so nothing may be added
145/// inside its JSON body); the bare token line is what
146/// [`agent_result::token_reported_in_capture`] matches. Folding the token into
147/// the marker's JSON would couple this prompt to `AgentResult`'s schema for no
148/// gain.
149pub fn canary_prompt(token: &str) -> String {
150    format!(
151        "DevFlow startup check of Claude Code's background-task notification path. \
152         Do exactly the following and nothing else — do not read, create or modify any file, \
153         and do not run any command.\n\
154         \n\
155         1. Dispatch ONE background task whose entire job is to reply with the word `ok`.\n\
156         2. Wait for that task's completion notification to arrive. Do not finish before it does.\n\
157         3. In the turn that follows that notification, end your message with these two lines, \
158         each on its own line and exactly as written:\n\
159         \n\
160         {token}\n\
161         DEVFLOW_RESULT: {{\"status\":\"success\"}}\n\
162         \n\
163         The first line is a single-use token supplied by DevFlow. Reproduce it character for \
164         character; do not shorten, summarise, quote or comment on it."
165    )
166}
167
168/// Run one delivery canary and report what it established.
169///
170/// Declares a fresh token, plants it in a throwaway prompt, runs `launcher`
171/// against a capture inside `capture_dir`, and hands the resulting capture text
172/// to [`agent_result::token_reported_in_capture`] — the one function in this
173/// codebase that decides whether a token came back from somewhere trustworthy.
174/// See this module's header for why that decision is not made here.
175/// Every failure mode below is `Unverified`, never `Absent`. `Absent` is a
176/// claim about the CLI's behaviour and may only be made after the CLI actually
177/// ran and produced a capture that could be read.
178pub fn run_delivery_canary<L: CanaryLauncher>(launcher: &L, capture_dir: &Path) -> CanaryOutcome {
179    let token = declare_token();
180    let capture = canary_capture_path(capture_dir);
181
182    // Through `ensure_devflow_dir` rather than a bare `create_dir_all`: it also
183    // self-protects a `.devflow` in the path with a `*` .gitignore, and the
184    // canary capture is agent output that must not be sweepable into a
185    // downstream repo by a routine `git add .` (T-31-13, ROADMAP §999.69).
186    if let Err(err) = crate::workflow::ensure_devflow_dir(capture_dir) {
187        return CanaryOutcome::Unverified(format!(
188            "could not prepare the canary capture directory {}: {err}",
189            capture_dir.display()
190        ));
191    }
192
193    if let Err(reason) = launcher.run(&canary_prompt(&token), &capture) {
194        return CanaryOutcome::Unverified(reason);
195    }
196
197    // Lossy decode, matching the ONE capture-decode policy the rest of this
198    // codebase reads through (`agent_result::read_capture`, CR-01): a single
199    // invalid UTF-8 byte from a raw pipe must not silently disable the guard.
200    // REPLACE rather than drop — dropping joins the tokens on either side.
201    let text = match std::fs::read(&capture) {
202        Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
203        Err(err) => {
204            return CanaryOutcome::Unverified(format!(
205                "the canary ran but its capture {} could not be read: {err}",
206                capture.display()
207            ));
208        }
209    };
210
211    if agent_result::token_reported_in_capture(&text, &token) {
212        CanaryOutcome::Confirmed
213    } else {
214        CanaryOutcome::Absent
215    }
216}
217
218/// How long the canary waits with NOTHING arriving on the child's stdout before
219/// concluding nothing more is coming.
220///
221/// Deliberately its OWN constant rather than a reuse of the stage monitor's
222/// idle timeout: the canary waits for one trivial background task, the monitor
223/// waits for a whole stage, and coupling them would let a future change to the
224/// stage timeout silently change how patient the guard is. That separation is
225/// still right — but it is exactly why this constant has to be re-derived when
226/// the evidence moves, rather than tracking the other one for free.
227///
228/// **Raised 30s -> 120s on 2026-08-03. The previous value's stated "~4x margin"
229/// was refuted by measurement.** That figure came from Phase 30d's *backgrounded*
230/// 10s/22s sleeps. Direct measurement (CLI 2.1.220, five workload-controlled
231/// trials, two workload types, negative control) found the CLI emits
232/// `tool_progress` keepalives on a **fixed 30.00s interval**, with the first gap
233/// after `task_started` consistently ~26.4s. So 30s of stream silence is normal
234/// healthy behaviour, and a 30s patience budget had roughly 1.1x margin, not 4x.
235/// See `IDLE_TIMEOUT_FLOOR_SECS` in `monitor.rs` and the phase's
236/// `31-IDLE-GAP-MEASUREMENTS.md`.
237///
238/// **Why a false `Absent` is the expensive direction here.** This guard *refuses
239/// to run* on `Absent`/`Unverified` (D-15). A canary that gives up during a
240/// normal keepalive gap does not degrade the run — it locks the operator out of
241/// every `stream-json` launch until they diagnose it. Being slower to detect a
242/// genuinely dead delivery path costs one wait, bounded anyway by
243/// [`CANARY_DEADLINE_SECS`]; being wrong in the other direction costs the tool.
244const CANARY_IDLE_SECS: u64 = 120;
245
246/// Absolute wall-clock cap on one canary run.
247///
248/// The guard runs SYNCHRONOUSLY inside the operator's `devflow start`, so a
249/// child that never speaks and never exits would wedge the launch outright.
250/// The idle timeout above already covers a silent child; this covers a chatty
251/// one that never converges.
252const CANARY_DEADLINE_SECS: u64 = 300;
253
254/// How long the child gets to exit on its own after its stdin is released,
255/// before being killed.
256const CANARY_REAP_GRACE_SECS: u64 = 10;
257
258/// Poll interval while reaping.
259const REAP_POLL: Duration = Duration::from_millis(100);
260
261/// The real launcher: runs one throwaway `claude` turn over the same
262/// bidirectional `stream-json` transport a production stage uses.
263///
264/// **Nothing in this plan's test suite executes this type.** Every test injects
265/// a launcher that writes a canned capture, by design — a guard whose own tests
266/// spend real agent invocations is a guard nobody runs. The consequence is that
267/// this implementation is reasoned, not witnessed; plan 31-05's acceptance run
268/// against the real CLI is what witnesses it.
269pub struct ClaudeCanaryLauncher {
270    /// Working directory for the throwaway child.
271    ///
272    /// Carried as a field because [`CanaryLauncher::run`] has nowhere to put a
273    /// cwd and [`hermetic_command`] requires one. Deriving it from the capture
274    /// path instead would silently couple the child's working directory to
275    /// where DevFlow happens to keep its runtime files.
276    pub workdir: PathBuf,
277}
278
279impl CanaryLauncher for ClaudeCanaryLauncher {
280    fn run(&self, prompt: &str, capture: &Path) -> Result<(), String> {
281        // Phase 0 — the codebase's "not attributable to a real phase" sentinel
282        // (see `advance`'s `events::emit(project_root, 0, …)`). `exec_command`
283        // ignores both the phase and the prompt: under `--input-format
284        // stream-json` the prompt travels on stdin, not argv.
285        let (program, args) = ClaudeAgent.exec_command(PhaseId::new(0), prompt, &[]);
286
287        let mut capture_file = std::fs::File::create(capture).map_err(|err| {
288            format!(
289                "could not create the canary capture {}: {err}",
290                capture.display()
291            )
292        })?;
293
294        // No `.process_group(0)` here, deliberately — the opposite choice from
295        // `run_pipe_owning_monitor`'s detached child. This one runs in the
296        // FOREGROUND of the operator's own CLI, so it should stay in the
297        // terminal's process group and die with a Ctrl-C like any other
298        // foreground child. Group isolation would leave a canary running with
299        // nothing left to reap it.
300        let mut child = hermetic_command(program, &self.workdir)
301            .args(&args)
302            .stdin(Stdio::piped())
303            .stdout(Stdio::piped())
304            // stderr is discarded rather than teed: the capture must stay
305            // parseable JSONL, and nothing reads a canary's diagnostics.
306            .stderr(Stdio::null())
307            .spawn()
308            .map_err(|err| format!("could not run `{program}`: {err}"))?;
309
310        let mut child_stdin = child
311            .stdin
312            .take()
313            .ok_or_else(|| "the canary child exposed no stdin pipe".to_string())?;
314        let child_stdout = child
315            .stdout
316            .take()
317            .ok_or_else(|| "the canary child exposed no stdout pipe".to_string())?;
318
319        // Same three-participant threading model as the production monitor, and
320        // for the same reason (T-31-04): writing the turn synchronously before
321        // reading stdout is the textbook two-pipe deadlock.
322        let (close_tx, close_rx) = mpsc::channel::<()>();
323        let turn = monitor::user_turn_line(prompt);
324        let writer = std::thread::spawn(move || {
325            let wrote = child_stdin
326                .write_all(turn.as_bytes())
327                .and_then(|()| child_stdin.write_all(b"\n"))
328                .and_then(|()| child_stdin.flush());
329            if let Err(err) = wrote {
330                warn!("could not write the canary's user turn to the child's stdin: {err}");
331                return;
332            }
333            // Held open past the first turn ON PURPOSE. Releasing it here would
334            // end the session before any task-notification turn could be
335            // delivered — which is the very behaviour being measured, so the
336            // guard would report `Absent` against a perfectly healthy CLI.
337            let _ = close_rx.recv();
338            drop(child_stdin);
339        });
340
341        let (line_tx, line_rx) = mpsc::channel::<String>();
342        let reader = std::thread::spawn(move || {
343            for line in BufReader::new(child_stdout).lines() {
344                let Ok(line) = line else {
345                    break;
346                };
347                if let Err(err) = writeln!(capture_file, "{line}") {
348                    warn!("could not append to the canary capture: {err}");
349                }
350                let _ = capture_file.flush();
351                if line_tx.send(line).is_err() {
352                    break;
353                }
354            }
355        });
356
357        // The SAME close rule the production monitor applies (constraint 4's
358        // AND: a top-level marker plus a drained background-task list), reused
359        // rather than reimplemented. This governs only when stdin is released —
360        // it is a lifecycle decision, not the trust decision. The trust
361        // decision is made once, afterwards, by `run_delivery_canary`.
362        let mut rule = CloseRule::default();
363        let mut close_signalled = false;
364        let idle = Duration::from_secs(CANARY_IDLE_SECS);
365        let deadline = Instant::now() + Duration::from_secs(CANARY_DEADLINE_SECS);
366
367        loop {
368            let remaining = deadline.saturating_duration_since(Instant::now());
369            if remaining.is_zero() {
370                break;
371            }
372            match line_rx.recv_timeout(idle.min(remaining)) {
373                Ok(line) => {
374                    if close_signalled {
375                        continue;
376                    }
377                    rule.observe(&line);
378                    if rule.should_close() {
379                        let _ = close_tx.send(());
380                        close_signalled = true;
381                    }
382                }
383                // Idle expiry, deadline expiry and stdout EOF all mean the same
384                // thing here: stop waiting and go read what was captured. A
385                // timeout is NOT an error — a child that ran and said nothing
386                // useful is a fact about the CLI, and belongs in the
387                // `Absent` decision rather than in `Unverified`.
388                Err(mpsc::RecvTimeoutError::Disconnected | mpsc::RecvTimeoutError::Timeout) => {
389                    break;
390                }
391            }
392        }
393
394        // Release stdin before waiting: a child still holding an open stdin may
395        // never exit on its own.
396        drop(close_tx);
397        reap(&mut child);
398        let _ = writer.join();
399        let _ = reader.join();
400        Ok(())
401    }
402}
403
404/// Wait a bounded time for the canary child to exit, then kill it.
405///
406/// `try_wait`/`kill`/`wait` rather than [`crate::agent::terminate_and_verify`]:
407/// that helper polls `/proc` liveness, and this child is a DIRECT child of the
408/// current process, so it becomes an unreaped zombie whose `/proc` entry
409/// outlives it — the liveness poll would report a dead child as alive for the
410/// full timeout. `wait()` is the correct liveness answer for a direct child.
411///
412/// Known limitation, recorded rather than solved: this signals the child only,
413/// not a process group, so a descendant the canary child itself spawned can
414/// outlive the kill. The canary child is short-lived, capped by
415/// [`CANARY_DEADLINE_SECS`], and dispatches a task that touches nothing.
416fn reap(child: &mut std::process::Child) {
417    let deadline = Instant::now() + Duration::from_secs(CANARY_REAP_GRACE_SECS);
418    loop {
419        match child.try_wait() {
420            Ok(Some(_)) => return,
421            Ok(None) => {}
422            Err(err) => {
423                warn!("could not poll the canary child: {err}");
424                return;
425            }
426        }
427        if Instant::now() >= deadline {
428            break;
429        }
430        std::thread::sleep(REAP_POLL);
431    }
432    let _ = child.kill();
433    let _ = child.wait();
434}
435
436/// The `claude --version` string, for the run's provenance.
437///
438/// Recorded alongside a canary outcome so a later forensic read can tell WHICH
439/// CLI the behaviour was (or was not) witnessed on — the whole premise is
440/// version-fragile, and an outcome with no version attached cannot be compared
441/// against a later one. Fail-soft: `None` when the binary is missing or says
442/// nothing, because a guard's provenance must never be the reason a launch
443/// fails.
444///
445/// This is NOT the guard. A version string is a proxy for the behaviour, which
446/// is exactly what D-13 rejected; it is recorded as context beside the real
447/// measurement, never in place of it.
448pub fn claude_cli_version() -> Option<String> {
449    let output = std::process::Command::new("claude")
450        .arg("--version")
451        .output()
452        .ok()?;
453    if !output.status.success() {
454        return None;
455    }
456    let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
457    (!version.is_empty()).then_some(version)
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    // ---- fixtures --------------------------------------------------------
465    //
466    // Event shapes are taken from the real archived capture at
467    // `.planning/phases/30-keep-the-session-alive-past-turn-end/30a-evidence/
468    // raw_output_v3.jsonl` (lines 5, 19, 54) by way of 31-RESEARCH.md § "Code
469    // Examples": a `system`/`init` line, a top-level `result` carrying the
470    // agent's own final text, and the echoed `user` turn the CLI writes back
471    // into the same stdout. Identifiers are generalized; shapes are not.
472
473    const INIT_LINE: &str = r#"{"type":"system","subtype":"init","cwd":"/tmp/work","session_id":"s-1","claude_code_version":"2.1.220","uuid":"u-init"}"#;
474
475    /// The CLI's echo of the operator's prompt, re-emitted as a `user` event.
476    /// This is the shape that produced the checkpoint false positive 30-05
477    /// fixed, and the reason the canary may never substring-scan the capture.
478    fn echoed_prompt_line(prompt: &str) -> String {
479        serde_json::json!({
480            "type": "user",
481            "message": { "role": "user", "content": prompt },
482            "session_id": "s-1",
483            "uuid": "u-echo",
484        })
485        .to_string()
486    }
487
488    /// A TOP-LEVEL `result` event — no `parent_tool_use_id`, so the
489    /// orchestrator session authored it.
490    fn top_level_result_line(text: &str) -> String {
491        serde_json::json!({
492            "type": "result",
493            "subtype": "success",
494            "is_error": false,
495            "num_turns": 3,
496            "stop_reason": "end_turn",
497            "session_id": "s-1",
498            "uuid": "u-res",
499            "result": text,
500        })
501        .to_string()
502    }
503
504    /// A `result` event forwarded from a SUBAGENT — same type, non-null
505    /// `parent_tool_use_id`, therefore not the orchestrator speaking.
506    fn subagent_result_line(text: &str) -> String {
507        serde_json::json!({
508            "type": "result",
509            "subtype": "success",
510            "is_error": false,
511            "session_id": "s-1",
512            "uuid": "u-sub",
513            "parent_tool_use_id": "toolu_01CanarySubagent",
514            "result": text,
515        })
516        .to_string()
517    }
518
519    /// Recover the declared token from the prompt the canary handed the
520    /// launcher — the same way the real agent gets it. Keeps the tests honest:
521    /// the token is generated inside `run_delivery_canary`, so a test launcher
522    /// that hard-coded one would be answering a question nobody asked.
523    fn token_in(prompt: &str) -> String {
524        let start = prompt
525            .find(TOKEN_PREFIX)
526            .expect("the canary prompt must carry the declared token");
527        let rest = &prompt[start + TOKEN_PREFIX.len()..];
528        let suffix: String = rest.chars().take_while(char::is_ascii_hexdigit).collect();
529        assert!(
530            !suffix.is_empty(),
531            "the token in the prompt must have a body after its prefix"
532        );
533        format!("{TOKEN_PREFIX}{suffix}")
534    }
535
536    /// A launcher that writes whatever `lines` the test asked for, given the
537    /// token it found in the prompt. Records the prompt it was handed and how
538    /// many times it ran.
539    struct CannedLauncher<F: Fn(&str) -> Vec<String>> {
540        lines: F,
541    }
542
543    impl<F: Fn(&str) -> Vec<String>> CanaryLauncher for CannedLauncher<F> {
544        fn run(&self, prompt: &str, capture: &Path) -> Result<(), String> {
545            let token = token_in(prompt);
546            let body = (self.lines)(&token).join("\n");
547            std::fs::write(capture, format!("{body}\n")).map_err(|err| err.to_string())?;
548            Ok(())
549        }
550    }
551
552    /// A launcher that could not run at all — a missing binary, a permission
553    /// error, a spawn failure. It writes no capture.
554    struct FailingLauncher(&'static str);
555
556    impl CanaryLauncher for FailingLauncher {
557        fn run(&self, _prompt: &str, _capture: &Path) -> Result<(), String> {
558            Err(self.0.to_string())
559        }
560    }
561
562    /// The token came back inside a top-level `result` — the notification path
563    /// is alive.
564    #[test]
565    fn canary_confirmed_when_token_returns_in_a_top_level_result() {
566        let dir = tempfile::tempdir().unwrap();
567
568        let launcher = CannedLauncher {
569            lines: |token| {
570                vec![
571                    INIT_LINE.to_string(),
572                    top_level_result_line(&format!(
573                        "The background task finished.\n{token}\nDEVFLOW_RESULT: {{\"status\":\"success\"}}"
574                    )),
575                ]
576            },
577        };
578
579        let outcome = run_delivery_canary(&launcher, dir.path());
580
581        assert_eq!(
582            outcome,
583            CanaryOutcome::Confirmed,
584            "a token inside a top-level result is the whole point of the guard"
585        );
586    }
587
588    /// D-13 trap 1, and the single most important test in this module: the CLI
589    /// echoes the prompt back, so the planted token appears in the capture
590    /// whether or not anything was delivered. A canary that scanned the capture
591    /// would certify delivery that never happened.
592    #[test]
593    fn canary_absent_when_token_appears_only_as_a_prompt_echo() {
594        let dir = tempfile::tempdir().unwrap();
595
596        let launcher = CannedLauncher {
597            lines: |token| {
598                vec![
599                    INIT_LINE.to_string(),
600                    // The echo carries the token verbatim …
601                    echoed_prompt_line(&canary_prompt(token)),
602                    // … while the agent's own final word does not.
603                    top_level_result_line("I could not dispatch a background task."),
604                ]
605            },
606        };
607
608        let outcome = run_delivery_canary(&launcher, dir.path());
609
610        // Negative control for the assertion below: if the capture did not
611        // contain the token at all, `Absent` would be true for an entirely
612        // uninteresting reason and this test would be measuring nothing.
613        // Checked per LINE and by parsing, not by slicing the raw text —
614        // `serde_json` writes object keys in sorted order, so `"type":"user"`
615        // lands AFTER the message body that carries the token and a
616        // position-based check reads backwards.
617        let capture = std::fs::read_to_string(canary_capture_path(dir.path())).unwrap();
618        let carrying: Vec<serde_json::Value> = capture
619            .lines()
620            .filter(|line| line.contains(TOKEN_PREFIX))
621            .map(|line| serde_json::from_str(line).expect("fixture lines are JSON"))
622            .collect();
623        assert!(
624            !carrying.is_empty(),
625            "fixture must actually contain the echoed token"
626        );
627        assert!(
628            carrying.iter().all(|event| event["type"] == "user"),
629            "fixture must place the echoed token ONLY inside a `user` event — \
630             if any result event carries it, this test is not exercising the echo case"
631        );
632
633        assert_eq!(
634            outcome,
635            CanaryOutcome::Absent,
636            "an echoed token must never satisfy the guard (30-05's false positive)"
637        );
638    }
639
640    /// Provenance, the second half of trap 1: a `result` forwarded from a
641    /// subagent is the right event TYPE and the wrong AUTHOR.
642    #[test]
643    fn canary_absent_when_token_appears_only_in_a_non_top_level_event() {
644        let dir = tempfile::tempdir().unwrap();
645
646        let launcher = CannedLauncher {
647            lines: |token| {
648                vec![
649                    INIT_LINE.to_string(),
650                    subagent_result_line(&format!("child reporting: {token}")),
651                    top_level_result_line("Done."),
652                ]
653            },
654        };
655
656        let outcome = run_delivery_canary(&launcher, dir.path());
657
658        // Same negative control: prove the token is present before concluding
659        // anything from its not being honoured.
660        let capture = std::fs::read_to_string(canary_capture_path(dir.path())).unwrap();
661        assert!(
662            capture.contains(TOKEN_PREFIX),
663            "fixture must actually contain the token inside the subagent result"
664        );
665        assert!(
666            capture.contains("parent_tool_use_id"),
667            "fixture must actually mark that result as subagent-authored"
668        );
669
670        assert_eq!(
671            outcome,
672            CanaryOutcome::Absent,
673            "a subagent-authored result must not certify orchestrator-level delivery"
674        );
675    }
676
677    /// "The CLI could not be run" is not "the CLI ran and the behaviour is
678    /// gone". Collapsing the two would report a missing binary as a broken
679    /// premise and send the operator after the wrong problem entirely.
680    #[test]
681    fn canary_unverified_when_the_launcher_fails() {
682        let dir = tempfile::tempdir().unwrap();
683
684        let outcome = run_delivery_canary(
685            &FailingLauncher("could not run `claude`: No such file or directory (os error 2)"),
686            dir.path(),
687        );
688
689        match outcome {
690            CanaryOutcome::Unverified(reason) => {
691                assert!(
692                    reason.contains("No such file or directory"),
693                    "the reason the guard could not run must survive into the outcome, \
694                     got: {reason}"
695                );
696            }
697            other => panic!("a launcher failure must be Unverified, not {other:?}"),
698        }
699    }
700
701    /// A token reused across runs would let a stale capture satisfy a later
702    /// guard.
703    #[test]
704    fn declared_tokens_differ_between_runs() {
705        let first = declare_token();
706        let second = declare_token();
707
708        assert_ne!(
709            first, second,
710            "each canary run must declare its own token, or a stale capture could satisfy it"
711        );
712        assert!(
713            first.starts_with(TOKEN_PREFIX) && second.starts_with(TOKEN_PREFIX),
714            "both tokens must carry the greppable prefix"
715        );
716    }
717}