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, ClaudeDriver};
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) = ClaudeDriver.build_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}