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