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}