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