Skip to main content

kranz_engine/
control.rs

1//! Cross-process control inbox (design.md "Cross-process control").
2//!
3//! The single-writer rule (§4.3) means only the engine appends `events.jsonl`.
4//! Other processes (CLI `kranz msg/pause/resume`, the server) talk to a
5//! running engine by dropping [`ControlCommand`] JSON files into
6//! `paths.control_dir()`. File names are `<zero-padded-nanos>-<8-hex>.json`
7//! so lexicographic order == chronological order; writes go through a tmp
8//! file + rename so a draining engine never observes a partial file. The
9//! nanosecond prefix keeps back-to-back enqueues (e.g. `pause` immediately
10//! followed by `resume`) in issue order — a millisecond prefix left same-ms
11//! enqueues to be ordered by the random suffix.
12//!
13//! The engine [`drain`]s the inbox between worker runs, and a
14//! [`ControlWatcher`] polls [`peek_interrupt`] so an `interrupt` message can
15//! abort the active run.
16//!
17//! # Authenticity (audit 2026-09-01 C1)
18//!
19//! The inbox carries operator consent: `approve-grant`, `approve-revision`,
20//! `answer-question`, and `config-change` all land as events that are
21//! indistinguishable from a human decision. It used to be a plain directory
22//! any process with write access to the repo could drop a file into, which
23//! made forging consent a one-file operation for a worker, a validator, or
24//! any gate command.
25//!
26//! Every file therefore carries a `sig`: a hex HMAC-SHA256 over the mission
27//! id, the file name, and the canonical JSON of the command, keyed by the
28//! repository authority key ([`crate::paths::authority_key_path`]). [`drain`]
29//! and [`peek_interrupt`] verify it with a constant-time compare and
30//! quarantine anything unsigned or wrongly signed to `.bad`, never applying
31//! it. [`acknowledge`] records the acknowledged file's name as the mission's
32//! control mark outside the repository, and any later file whose name is at
33//! or below the mark is quarantined as a replay even when its signature
34//! verifies.
35
36use crate::error::{EngineError, Result};
37use crate::paths::MissionPaths;
38use crate::types::{ControlCommand, MissionStatus};
39use chrono::Utc;
40use std::ffi::{OsStr, OsString};
41use std::io::{ErrorKind, Write};
42use std::path::{Path, PathBuf};
43use subtle::ConstantTimeEq as _;
44
45/// Width of the zero-padded nanosecond prefix — the full `u64` decimal
46/// width, so names sort lexicographically for any conceivable timestamp.
47const TIMESTAMP_WIDTH: usize = 20;
48
49/// Length of the random hex suffix (a `uuid` v4 `simple()` prefix).
50const RAND_LEN: usize = 8;
51
52/// JSON key holding the authenticity tag of a control file.
53const SIG_FIELD: &str = "sig";
54
55/// The bytes a control file's `sig` covers: the mission id and the FILE
56/// NAME, each length-prefixed so no two fields can be re-cut into a
57/// different pair, then the canonical JSON of the command.
58///
59/// Signing the re-serialized `ControlCommand` rather than the raw file bytes
60/// binds the MEANING of the file, not its whitespace, and keeps the signer
61/// and the verifier on one code path: both go `ControlCommand` -> canonical
62/// JSON -> HMAC, so they cannot drift on key order or number formatting.
63///
64/// Binding the name makes a signed file single-use together with the
65/// control mark (`paths::record_control_mark`): names order by creation
66/// time, the engine records the last name it acknowledged, and a captured
67/// file re-dropped later carries a name at or below that mark (follow-up
68/// review F-1).
69fn signed_payload(mission_id: &str, name: &str, cmd: &ControlCommand) -> Result<String> {
70    let body = serde_json::to_string(cmd)?;
71    Ok(format!(
72        "{}:{mission_id}\n{}:{name}\n{body}",
73        mission_id.len(),
74        name.len()
75    ))
76}
77
78/// Hex HMAC-SHA256 of [`signed_payload`] under the repository authority key.
79fn sign(key: &[u8], mission_id: &str, name: &str, cmd: &ControlCommand) -> Result<String> {
80    Ok(crate::hooks::hmac_sha256_hex(
81        key,
82        signed_payload(mission_id, name, cmd)?.as_bytes(),
83    ))
84}
85
86/// A file whose name is at or below the recorded control mark was already
87/// acknowledged once, or predates one that was: a replay, whatever its
88/// signature says. Names are fixed-width, so string order is time order.
89fn is_replayed(repo_root: &Path, mission_id: &str, name: &str) -> bool {
90    crate::paths::read_control_mark(repo_root, mission_id).is_some_and(|mark| name <= mark.as_str())
91}
92
93/// Why a queued file was refused. Distinguishing the two matters: a forged or
94/// unsigned file is quarantined, whereas an operator whose key is temporarily
95/// unreadable must NOT have their inbox renamed away underneath them.
96enum ControlRefusal {
97    /// The file is not an authentic command; quarantine it.
98    Quarantine(String),
99    /// This process cannot verify right now; leave the file queued.
100    Skip(String),
101}
102
103/// Parse and authenticate one control file's contents.
104///
105/// Fails CLOSED on an unsigned file. A control file written by a kranz that
106/// predates signing carries no `sig` and is refused for that reason: the
107/// on-disk format is otherwise unchanged, but an unauthenticated command is
108/// exactly the thing this guard exists to stop, so old files are quarantined
109/// rather than grandfathered.
110fn authenticate(
111    repo_root: &Path,
112    mission_id: &str,
113    name: &str,
114    content: &str,
115) -> std::result::Result<ControlCommand, ControlRefusal> {
116    if is_replayed(repo_root, mission_id, name) {
117        return Err(ControlRefusal::Quarantine(
118            "control command replays a file the engine already acknowledged".to_string(),
119        ));
120    }
121    let mut value: serde_json::Value = serde_json::from_str(content)
122        .map_err(|e| ControlRefusal::Quarantine(format!("unparseable control command: {e}")))?;
123    let object = value.as_object_mut().ok_or_else(|| {
124        ControlRefusal::Quarantine("control command is not a JSON object".to_string())
125    })?;
126    let Some(presented) = object.remove(SIG_FIELD) else {
127        return Err(ControlRefusal::Quarantine(
128            "control command carries no signature".to_string(),
129        ));
130    };
131    let Some(presented) = presented.as_str().map(str::to_string) else {
132        return Err(ControlRefusal::Quarantine(
133            "control command signature is not a string".to_string(),
134        ));
135    };
136    let cmd: ControlCommand = serde_json::from_value(value)
137        .map_err(|e| ControlRefusal::Quarantine(format!("unparseable control command: {e}")))?;
138
139    // No key means no verdict, not a pass: skip and stay loud. Quarantining
140    // here would let a transient home-directory problem shred a real inbox.
141    let Some(key) = crate::paths::load_authority_key(repo_root) else {
142        return Err(ControlRefusal::Skip(
143            "no repository authority key available to verify control commands".to_string(),
144        ));
145    };
146    let expected = sign(&key, mission_id, name, &cmd)
147        .map_err(|e| ControlRefusal::Skip(format!("could not recompute signature: {e}")))?;
148    if bool::from(expected.as_bytes().ct_eq(presented.as_bytes())) {
149        Ok(cmd)
150    } else {
151        Err(ControlRefusal::Quarantine(
152            "control command signature does not verify".to_string(),
153        ))
154    }
155}
156
157/// Enqueue one command into the mission's control inbox.
158///
159/// Creates the mission tree and control directory if needed — no-follow (P1
160/// mission-path-no-follow): a symlinked `.kranz`/`missions`/mission dir or
161/// `control/` entry is refused, never followed, since an enqueue through a
162/// symlink would route control commands into another repository's mission.
163/// The JSON goes to a sibling tmp file, then atomically renames to
164/// `<zero-padded-nanos>-<8-hex>.json` — readers never see partial files.
165/// Returns the final file path.
166///
167/// The written object is the command plus a `sig` field ([`authenticate`]);
168/// minting the repository authority key on first use is part of enqueuing, so
169/// an operator never has to run a key-setup step before `kranz pause` works.
170pub fn enqueue(paths: &MissionPaths, cmd: &ControlCommand) -> Result<PathBuf> {
171    // Sign BEFORE touching the mission tree: a repo with no reachable
172    // authority key must fail loudly at the CLI rather than leave an
173    // unverifiable file the engine would quarantine minutes later.
174    let key = crate::paths::load_or_create_authority_key(&paths.repo_root)?;
175
176    let nanos = Utc::now().timestamp_nanos_opt().unwrap_or(0).max(0) as u64;
177    let rand = uuid::Uuid::new_v4().simple().to_string();
178    let name = format!(
179        "{nanos:0width$}-{}.json",
180        &rand[..RAND_LEN],
181        width = TIMESTAMP_WIDTH
182    );
183    // The name is part of what is signed (see `signed_payload`).
184    let signature = sign(&key, &paths.mission_id, &name, cmd)?;
185
186    let mission_dir = paths.open_mission_dir_nofollow(true)?;
187    let dir = paths.control_dir();
188    let control_dir = crate::paths::open_real_subdir(&mission_dir, "control", &dir, true)?;
189
190    let final_path = dir.join(&name);
191    let tmp_name = format!("{name}.tmp");
192
193    let json = {
194        let mut value = serde_json::to_value(cmd)?;
195        let object = value.as_object_mut().ok_or_else(|| {
196            EngineError::InvalidState("control command is not a JSON object".to_string())
197        })?;
198        object.insert(SIG_FIELD.to_string(), serde_json::Value::String(signature));
199        serde_json::to_string(&value)?
200    };
201    {
202        use cap_fs_ext::OpenOptionsFollowExt as _;
203        use cap_primitives::fs::FollowSymlinks;
204        let mut options = cap_std::fs::OpenOptions::new();
205        options
206            .write(true)
207            .create_new(true)
208            .follow(FollowSymlinks::No);
209        let mut file = control_dir.open_with(&tmp_name, &options)?.into_std();
210        file.write_all(json.as_bytes())?;
211        file.sync_data()?;
212    }
213    control_dir.rename(&tmp_name, &control_dir, &name)?;
214    Ok(final_path)
215}
216
217/// Drain the inbox: parse every queued `.json` file, oldest first,
218/// NON-destructively.
219///
220/// Each parsed command is returned together with the file it came from; the
221/// caller deletes each file only AFTER the command has been durably applied
222/// (e.g. appended to the event log). Deleting up front would lose commands if
223/// the process crashed between the drain and the apply — re-processing a file
224/// on the next drain is the safe failure mode (duplicates are tolerated
225/// downstream).
226///
227/// A file that fails to parse, or fails to authenticate, is renamed to
228/// `<name>.bad` (with a warning) and skipped so neither a corrupt file nor a
229/// forged one can block the queue. Non-`.json` files (tmp files, `.bad`
230/// quarantines) are ignored. Returns the commands in filename (==
231/// chronological) order.
232///
233/// # Trust model
234///
235/// The authority key lives under the operator's `~/.kranz`, never in the
236/// repository, and never inside a session's working tree. Under an enforced
237/// sandbox it sits in `authority_read_deny_paths`, so a contained session
238/// cannot read it and therefore cannot produce a `sig` this function accepts.
239/// Under the default `sandbox.enforce = off` there is no OS boundary, and the
240/// barrier is the agent CLI's own deny rules (`permissions::AUTHORITY_DENY`
241/// denies `Read`/`Edit`/`Write` on `~/.kranz/**` and on every mission's
242/// `control/**`). That is a weaker barrier than the sandbox and is stated as
243/// such: a session that escapes its own CLI's permission layer can still read
244/// the key. What no longer works is the one-file forgery this closes, where
245/// merely being able to write inside the repo was enough.
246pub fn drain(paths: &MissionPaths) -> Result<Vec<(PathBuf, ControlCommand)>> {
247    let mut commands = Vec::new();
248    let Some(control_dir) = control_dir(paths, false)? else {
249        return Ok(commands);
250    };
251    for name in queued_files(&control_dir)? {
252        let path = paths.control_dir().join(&name);
253        let content = match read_control_file(&control_dir, &name) {
254            Ok(c) => c,
255            Err(e) => {
256                // Transient (e.g. racing another drain); skip, never block.
257                tracing::warn!(path = %path.display(), error = %e, "unreadable control file, skipping");
258                continue;
259            }
260        };
261        match authenticate(
262            &paths.repo_root,
263            &paths.mission_id,
264            &name.to_string_lossy(),
265            &content,
266        ) {
267            Ok(cmd) => commands.push((path, cmd)),
268            Err(ControlRefusal::Quarantine(reason)) => {
269                quarantine(&control_dir, &name, &path, &reason)
270            }
271            Err(ControlRefusal::Skip(reason)) => {
272                tracing::warn!(path = %path.display(), reason = %reason, "cannot verify control file, leaving it queued");
273            }
274        }
275    }
276    Ok(commands)
277}
278
279/// True if any queued file AUTHENTICATES to `Msg { interrupt: true }`.
280///
281/// Non-destructive: nothing is consumed, deleted, or renamed — the engine's
282/// run-watcher polls this cheaply while a later [`drain`] still returns the
283/// message itself. Signature checking belongs here too, not only in [`drain`]:
284/// an unsigned file that aborted the active run would be a denial of service
285/// on every mission, delivered by the same one-file write.
286pub fn peek_interrupt(paths: &MissionPaths) -> Result<bool> {
287    let Some(control_dir) = control_dir(paths, false)? else {
288        return Ok(false);
289    };
290    for name in queued_files(&control_dir)? {
291        let Ok(content) = read_control_file(&control_dir, &name) else {
292            continue;
293        };
294        match authenticate(
295            &paths.repo_root,
296            &paths.mission_id,
297            &name.to_string_lossy(),
298            &content,
299        ) {
300            Ok(ControlCommand::Msg {
301                interrupt: true, ..
302            }) => return Ok(true),
303            Ok(_) | Err(ControlRefusal::Quarantine(_)) => {}
304            Err(ControlRefusal::Skip(reason)) => {
305                // The brake is unverifiable, not absent. Say so on every
306                // poll: a silent `false` would hide a lost key behind a run
307                // that simply never stops (follow-up review F-3).
308                tracing::warn!(
309                    path = %paths.control_dir().join(&name).display(),
310                    reason = %reason,
311                    "cannot verify a queued control file while checking for an interrupt"
312                );
313            }
314        }
315    }
316    Ok(false)
317}
318
319/// Remove a command returned by [`drain`] after its event has been durably
320/// applied. The delete stays relative to the same no-follow mission/control
321/// chain as enqueue and drain, so a swapped parent symlink cannot redirect
322/// acknowledgement outside the mission.
323pub fn acknowledge(paths: &MissionPaths, path: &Path) -> Result<()> {
324    if path.parent() != Some(paths.control_dir().as_path()) {
325        return Err(EngineError::InvalidState(format!(
326            "refusing control acknowledgement outside {}: {}",
327            paths.control_dir().display(),
328            path.display()
329        )));
330    }
331    let name = path.file_name().ok_or_else(|| {
332        EngineError::InvalidState(format!("control path {} has no file name", path.display()))
333    })?;
334    let Some(control_dir) = control_dir(paths, false)? else {
335        return Err(std::io::Error::from(ErrorKind::NotFound).into());
336    };
337    control_dir.remove_file(name)?;
338    // The file is gone; move the mark up to its name so the same bytes can
339    // never be drained twice (follow-up review F-1).
340    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
341        crate::paths::record_control_mark(&paths.repo_root, &paths.mission_id, name)?;
342    }
343    Ok(())
344}
345
346/// Fold one mission's status; `None` when its log is unreadable or absent.
347fn mission_status(repo_root: &Path, id: &str) -> Option<MissionStatus> {
348    let paths = MissionPaths::new(repo_root, id);
349    let events = crate::event_log::EventLog::read_events(&paths.events_file()).ok()?;
350    Some(crate::reducer::fold(&events).ok()?.mission.status)
351}
352
353/// Resolve the target mission for a mid-mission control command (config
354/// change, pause, resume): an ACTIVE (non-terminal) mission, chosen
355/// unambiguously. Shared by the Slack bridge (`/kranz config|pause|resume`)
356/// and the CLI (`kranz config role`), so both surfaces refuse the same
357/// hazardous targets:
358///
359/// - explicit id: must exist and be active. A terminal mission's control
360///   inbox is never drained (run() refuses terminal missions), so enqueuing
361///   there would be a silent no-op reported as success — reject it instead.
362/// - no id: exactly one active mission → use it; none → error; several →
363///   error listing the candidates and asking for an explicit id (an
364///   mtime-based guess could hijack the wrong running mission).
365pub fn resolve_active_mission(repo_root: &Path, explicit: Option<&str>) -> Result<String> {
366    let is_terminal = crate::mission_catalog::is_terminal_status;
367    if let Some(id) = explicit {
368        if !MissionPaths::is_safe_id(id) {
369            return Err(EngineError::Other(format!("unknown mission `{id}`")));
370        }
371        // A symlinked mission dir is refused (P1 mission-path-no-follow),
372        // never followed into another repository's mission.
373        if MissionPaths::new(repo_root, id)
374            .require_no_follow()
375            .is_err()
376        {
377            return Err(EngineError::Other(format!("unknown mission `{id}`")));
378        }
379        match mission_status(repo_root, id) {
380            None => Err(EngineError::Other(format!("unknown mission `{id}`"))),
381            Some(s) if is_terminal(s) => Err(EngineError::Other(format!(
382                "mission `{id}` is {s:?}; this change applies only to active missions"
383            ))),
384            Some(_) => Ok(id.to_string()),
385        }
386    } else {
387        let active: Vec<String> = MissionPaths::list_missions(repo_root)
388            .into_iter()
389            .filter(|id| mission_status(repo_root, id).is_some_and(|s| !is_terminal(s)))
390            .collect();
391        match active.len() {
392            0 => Err(EngineError::Other(
393                "no active mission — create one first".into(),
394            )),
395            1 => Ok(active.into_iter().next().expect("len == 1")),
396            _ => Err(EngineError::Other(format!(
397                "several active missions ({}); name one explicitly",
398                active.join(", ")
399            ))),
400        }
401    }
402}
403
404/// Open the control directory through the pinned mission capability. A
405/// missing mission/control directory is an empty inbox when `create` is
406/// false.
407fn control_dir(paths: &MissionPaths, create: bool) -> Result<Option<cap_std::fs::Dir>> {
408    let mission_dir = match paths.open_mission_dir_nofollow(create) {
409        Ok(dir) => dir,
410        Err(EngineError::Io(error)) if error.kind() == ErrorKind::NotFound && !create => {
411            return Ok(None)
412        }
413        Err(error) => return Err(error),
414    };
415    match crate::paths::open_real_subdir(&mission_dir, "control", &paths.control_dir(), create) {
416        Ok(dir) => Ok(Some(dir)),
417        Err(EngineError::Io(error)) if error.kind() == ErrorKind::NotFound && !create => Ok(None),
418        Err(error) => Err(error),
419    }
420}
421
422/// Queued `.json` command names in filename (== chronological) order.
423fn queued_files(dir: &cap_std::fs::Dir) -> Result<Vec<OsString>> {
424    let entries = dir.entries()?;
425    let mut files = Vec::new();
426    for entry in entries {
427        let entry = entry?;
428        let name = entry.file_name();
429        let is_file = entry.file_type().map(|t| t.is_file()).unwrap_or(false);
430        if is_file && Path::new(&name).extension().and_then(|e| e.to_str()) == Some("json") {
431            files.push(name);
432        }
433    }
434    files.sort();
435    Ok(files)
436}
437
438fn read_control_file(dir: &cap_std::fs::Dir, name: &OsStr) -> Result<String> {
439    use cap_fs_ext::OpenOptionsFollowExt as _;
440    use cap_primitives::fs::FollowSymlinks;
441    use std::io::Read;
442    let mut options = cap_std::fs::OpenOptions::new();
443    options.read(true).follow(FollowSymlinks::No);
444    let mut file = dir.open_with(name, &options)?.into_std();
445    let mut content = String::new();
446    file.read_to_string(&mut content)?;
447    Ok(content)
448}
449
450/// Rename an unparseable or inauthentic command file to `<name>.bad` so it
451/// stops blocking the queue but stays on disk for diagnosis.
452fn quarantine(dir: &cap_std::fs::Dir, name: &OsStr, path: &Path, reason: &str) {
453    let bad_name = format!("{}.bad", name.to_string_lossy());
454    tracing::warn!(
455        path = %path.display(),
456        error = %reason,
457        "refused control command, quarantining as .bad"
458    );
459    if let Err(e) = dir.rename(name, dir, &bad_name) {
460        tracing::warn!(path = %path.display(), error = %e, "failed to quarantine control file");
461    }
462}
463
464/// Spawn-able helper that polls [`peek_interrupt`] and fires a
465/// [`tokio::sync::Notify`] once an interrupt message is queued. The
466/// orchestrator selects on the notify alongside the active worker run.
467pub struct ControlWatcher;
468
469impl ControlWatcher {
470    /// Loop [`peek_interrupt`] every `poll` interval; when an interrupt is
471    /// seen, fire `notify.notify_one()` once and return. Poll errors are
472    /// logged and treated as "no interrupt yet" — the watcher never dies on a
473    /// transient filesystem hiccup.
474    ///
475    /// `notify_one` (never `notify_waiters`) is load-bearing: it stores a
476    /// permit when nobody is waiting, so a fire while the run loop is between
477    /// `notified()` registrations (or still inside `backend.start()`) is
478    /// consumed by the NEXT waiter instead of being lost forever. Tokio also
479    /// re-stores/passes on the permit when a woken `Notified` future is
480    /// dropped unconsumed, so a `select!` race cannot swallow it either.
481    pub async fn wait_for_interrupt(
482        paths: MissionPaths,
483        poll: std::time::Duration,
484        notify: std::sync::Arc<tokio::sync::Notify>,
485    ) {
486        loop {
487            match peek_interrupt(&paths) {
488                Ok(true) => {
489                    notify.notify_one();
490                    return;
491                }
492                Ok(false) => {}
493                Err(e) => {
494                    tracing::warn!(error = %e, "control watcher poll failed, retrying");
495                }
496            }
497            tokio::time::sleep(poll).await;
498        }
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use crate::events::{Event, EventKind};
506    use crate::types::MissionConfig;
507    use tempfile::TempDir;
508
509    /// Serialized `events.jsonl` lines for a `mission.created` event (and
510    /// optionally a `mission.completed`) that fold like a real log.
511    fn events_lines(mission_id: &str, completed: bool) -> String {
512        let mut lines = String::new();
513        let created = Event {
514            seq: 1,
515            ts: Utc::now(),
516            mission_id: mission_id.to_string(),
517            kind: EventKind::MissionCreated {
518                goal: "goal".into(),
519                base_branch: "main".into(),
520                mission_branch: format!("kranz/mission-{mission_id}"),
521                config: MissionConfig::default(),
522            },
523        };
524        lines.push_str(&serde_json::to_string(&created).unwrap());
525        lines.push('\n');
526        if completed {
527            let done = Event {
528                seq: 2,
529                ts: Utc::now(),
530                mission_id: mission_id.to_string(),
531                kind: EventKind::MissionCompleted {},
532            };
533            lines.push_str(&serde_json::to_string(&done).unwrap());
534            lines.push('\n');
535        }
536        lines
537    }
538
539    /// Seed a mission's `events.jsonl` under the repo's missions dir.
540    fn seed_mission(repo_root: &Path, mission_id: &str, completed: bool) {
541        let paths = MissionPaths::new(repo_root, mission_id);
542        std::fs::create_dir_all(paths.mission_dir()).unwrap();
543        std::fs::write(paths.events_file(), events_lines(mission_id, completed)).unwrap();
544    }
545
546    // -- C1: the inbox authenticates ---------------------------------------
547
548    /// Drop a raw JSON object into the inbox the way an agent process with
549    /// ordinary write access to the repo would: no `enqueue`, no key.
550    fn plant(paths: &MissionPaths, name: &str, body: serde_json::Value) {
551        std::fs::create_dir_all(paths.control_dir()).unwrap();
552        std::fs::write(
553            paths.control_dir().join(name),
554            serde_json::to_string(&body).unwrap(),
555        )
556        .unwrap();
557    }
558
559    fn quarantined(paths: &MissionPaths) -> Vec<String> {
560        std::fs::read_dir(paths.control_dir())
561            .unwrap()
562            .flatten()
563            .map(|e| e.file_name().to_string_lossy().into_owned())
564            .filter(|n| n.ends_with(".bad"))
565            .collect()
566    }
567
568    #[test]
569    fn unsigned_approve_grant_is_quarantined_and_never_drained() {
570        let tmp = TempDir::new().unwrap();
571        let paths = MissionPaths::new(tmp.path(), "m-1");
572        // Enqueue one real command first so the repo's authority key exists;
573        // without it drain would SKIP rather than quarantine, and the test
574        // would pass for the wrong reason.
575        enqueue(&paths, &ControlCommand::Pause).unwrap();
576        plant(
577            &paths,
578            "00000000000000000001-aaaaaaaa.json",
579            serde_json::json!({ "kind": "approve-grant", "command": "cargo publish" }),
580        );
581
582        let drained = drain(&paths).unwrap();
583        assert!(
584            drained
585                .iter()
586                .all(|(_, cmd)| !matches!(cmd, ControlCommand::ApproveGrant { .. })),
587            "an unsigned approve-grant must never reach the engine"
588        );
589        assert_eq!(
590            quarantined(&paths),
591            vec!["00000000000000000001-aaaaaaaa.json.bad".to_string()],
592        );
593    }
594
595    #[test]
596    fn wrong_key_signature_is_quarantined() {
597        let tmp = TempDir::new().unwrap();
598        let paths = MissionPaths::new(tmp.path(), "m-1");
599        enqueue(&paths, &ControlCommand::Pause).unwrap();
600        let cmd = ControlCommand::ApproveGrant {
601            command: "cargo publish".to_string(),
602        };
603        let forged = sign(
604            b"not the repository authority key",
605            &paths.mission_id,
606            "00000000000000000002-bbbbbbbb.json",
607            &cmd,
608        )
609        .unwrap();
610        plant(
611            &paths,
612            "00000000000000000002-bbbbbbbb.json",
613            serde_json::json!({
614                "kind": "approve-grant",
615                "command": "cargo publish",
616                "sig": forged,
617            }),
618        );
619
620        assert!(drain(&paths)
621            .unwrap()
622            .iter()
623            .all(|(_, cmd)| !matches!(cmd, ControlCommand::ApproveGrant { .. })));
624        assert_eq!(
625            quarantined(&paths),
626            vec!["00000000000000000002-bbbbbbbb.json.bad".to_string()],
627        );
628    }
629
630    #[test]
631    fn a_correctly_signed_command_drains() {
632        let tmp = TempDir::new().unwrap();
633        let paths = MissionPaths::new(tmp.path(), "m-1");
634        enqueue(
635            &paths,
636            &ControlCommand::ApproveGrant {
637                command: "cargo publish".to_string(),
638            },
639        )
640        .unwrap();
641
642        let drained = drain(&paths).unwrap();
643        assert_eq!(drained.len(), 1);
644        assert!(
645            matches!(&drained[0].1, ControlCommand::ApproveGrant { command } if command == "cargo publish"),
646            "{:?}",
647            drained[0].1
648        );
649        assert!(quarantined(&paths).is_empty());
650    }
651
652    #[test]
653    fn signed_fractional_config_values_keep_their_bits() {
654        let tmp = TempDir::new().unwrap();
655        let paths = MissionPaths::new(tmp.path(), "m-fractional");
656        let costs = [
657            0.3917785_f64,
658            f64::from_bits(0.3917785_f64.to_bits() + 1),
659            0.095758,
660        ];
661        for cost in costs {
662            enqueue(
663                &paths,
664                &ControlCommand::ConfigChange {
665                    patch: serde_json::json!({"worker": {"maxBudgetUsd": cost}}),
666                },
667            )
668            .unwrap();
669        }
670        let drained = drain(&paths).unwrap();
671        assert_eq!(drained.len(), costs.len());
672        for ((_, command), expected) in drained.iter().zip(costs) {
673            let ControlCommand::ConfigChange { patch } = command else {
674                panic!("wrong command")
675            };
676            assert_eq!(
677                patch["worker"]["maxBudgetUsd"].as_f64().unwrap().to_bits(),
678                expected.to_bits()
679            );
680        }
681        assert!(quarantined(&paths).is_empty());
682    }
683
684    #[test]
685    fn a_signature_from_another_mission_does_not_transfer() {
686        // The mission id is inside the signed payload, so lifting a valid
687        // file out of mission A's inbox into mission B's must not carry the
688        // approval with it.
689        let tmp = TempDir::new().unwrap();
690        let source = MissionPaths::new(tmp.path(), "m-a");
691        let target = MissionPaths::new(tmp.path(), "m-b");
692        let cmd = ControlCommand::ApproveGrant {
693            command: "cargo publish".to_string(),
694        };
695        let file = enqueue(&source, &cmd).unwrap();
696        let body = std::fs::read_to_string(&file).unwrap();
697        std::fs::create_dir_all(target.control_dir()).unwrap();
698        let name = "00000000000000000003-cccccccc.json";
699        std::fs::write(target.control_dir().join(name), body).unwrap();
700
701        assert!(drain(&target).unwrap().is_empty());
702        assert_eq!(quarantined(&target), vec![format!("{name}.bad")]);
703    }
704
705    #[test]
706    fn an_unsigned_interrupt_never_aborts_the_run() {
707        let tmp = TempDir::new().unwrap();
708        let paths = MissionPaths::new(tmp.path(), "m-1");
709        enqueue(&paths, &ControlCommand::Pause).unwrap();
710        plant(
711            &paths,
712            "00000000000000000004-dddddddd.json",
713            serde_json::json!({ "kind": "msg", "text": "stop", "interrupt": true }),
714        );
715        assert!(!peek_interrupt(&paths).unwrap());
716    }
717
718    #[test]
719    fn resolve_explicit_active_mission_is_accepted() {
720        let tmp = TempDir::new().unwrap();
721        seed_mission(tmp.path(), "m-a", false);
722        assert_eq!(
723            resolve_active_mission(tmp.path(), Some("m-a")).unwrap(),
724            "m-a"
725        );
726    }
727
728    #[test]
729    fn resolve_unknown_mission_is_an_error() {
730        let tmp = TempDir::new().unwrap();
731        let err = resolve_active_mission(tmp.path(), Some("m-nope"))
732            .unwrap_err()
733            .to_string();
734        assert!(
735            err.contains("m-nope"),
736            "error names the unknown mission: {err}"
737        );
738    }
739
740    #[test]
741    fn resolve_explicit_mission_rejects_path_traversal_before_reading() {
742        let tmp = TempDir::new().unwrap();
743        let repo_root = tmp.path().join("nested").join("repo");
744        std::fs::create_dir_all(repo_root.join(".kranz").join("missions")).unwrap();
745        // Seed a real, foldable ACTIVE mission log at the traversal TARGET:
746        // from `<repo>/.kranz/missions/<id>`, the `../../..` id below lands
747        // in `<tmp>/nested/outside-target/`. An empty repo would mask a
748        // reverted guard — every traversal id would fail as "unknown mission"
749        // for the wrong reason.
750        let outside = tmp.path().join("nested").join("outside-target");
751        std::fs::create_dir_all(&outside).unwrap();
752        std::fs::write(
753            outside.join("events.jsonl"),
754            events_lines("outside-target", false),
755        )
756        .unwrap();
757        let traversal_id = "../../../outside-target";
758        // Fixture liveness: the traversal id really folds to an active
759        // mission, so a reverted `is_safe_id` guard would ACCEPT it.
760        assert!(
761            mission_status(&repo_root, traversal_id)
762                .is_some_and(|status| !crate::mission_catalog::is_terminal_status(status)),
763            "fixture: traversal target must fold as an active mission"
764        );
765
766        for id in [traversal_id, "a/b", r"a\b", "C:escape"] {
767            let error = resolve_active_mission(&repo_root, Some(id))
768                .unwrap_err()
769                .to_string();
770            assert!(error.contains("unknown mission"), "{id}: {error}");
771        }
772    }
773
774    #[test]
775    fn resolve_terminal_mission_is_an_error() {
776        let tmp = TempDir::new().unwrap();
777        seed_mission(tmp.path(), "m-done", true);
778        let err = resolve_active_mission(tmp.path(), Some("m-done"))
779            .unwrap_err()
780            .to_string();
781        assert!(
782            err.contains("active missions"),
783            "honest error, not false success: {err}"
784        );
785    }
786
787    #[test]
788    fn resolve_bare_uses_the_single_active_mission() {
789        let tmp = TempDir::new().unwrap();
790        seed_mission(tmp.path(), "m-only", false);
791        // A terminal sibling does not make the bare form ambiguous.
792        seed_mission(tmp.path(), "m-done", true);
793        assert_eq!(resolve_active_mission(tmp.path(), None).unwrap(), "m-only");
794    }
795
796    #[test]
797    fn resolve_bare_with_no_active_mission_is_an_error() {
798        let tmp = TempDir::new().unwrap();
799        assert!(resolve_active_mission(tmp.path(), None).is_err());
800    }
801
802    #[test]
803    fn resolve_bare_with_several_active_missions_refuses_and_lists_them() {
804        let tmp = TempDir::new().unwrap();
805        seed_mission(tmp.path(), "m-a", false);
806        seed_mission(tmp.path(), "m-b", false);
807        let err = resolve_active_mission(tmp.path(), None)
808            .unwrap_err()
809            .to_string();
810        assert!(err.contains("several active missions"), "{err}");
811        assert!(
812            err.contains("m-a") && err.contains("m-b"),
813            "candidates listed: {err}"
814        );
815    }
816
817    // Symlink-creating tests are unix-only, exactly like the lessons guard's
818    // tests; Windows needs privileges to create symlinks.
819
820    #[cfg(unix)]
821    #[test]
822    fn resolve_explicit_mission_refuses_a_symlinked_mission_dir() {
823        use std::os::unix::fs::symlink;
824        let tmp = TempDir::new().unwrap();
825        let elsewhere = TempDir::new().unwrap();
826        // The symlink target holds a foldable ACTIVE mission, so a reverted
827        // guard would ACCEPT the id instead of refusing it.
828        seed_mission(elsewhere.path(), "m-evil", false);
829        let missions = tmp.path().join(".kranz").join("missions");
830        std::fs::create_dir_all(&missions).unwrap();
831        symlink(
832            elsewhere
833                .path()
834                .join(".kranz")
835                .join("missions")
836                .join("m-evil"),
837            missions.join("m-evil"),
838        )
839        .unwrap();
840        let err = resolve_active_mission(tmp.path(), Some("m-evil"))
841            .unwrap_err()
842            .to_string();
843        assert!(err.contains("unknown mission"), "{err}");
844    }
845
846    #[cfg(unix)]
847    #[test]
848    fn enqueue_refuses_a_symlinked_mission_dir_without_touching_the_target() {
849        use std::os::unix::fs::symlink;
850        let tmp = TempDir::new().unwrap();
851        let elsewhere = TempDir::new().unwrap();
852        let missions = tmp.path().join(".kranz").join("missions");
853        std::fs::create_dir_all(&missions).unwrap();
854        symlink(elsewhere.path(), missions.join("m-evil")).unwrap();
855        let paths = MissionPaths::new(tmp.path(), "m-evil");
856        let err = enqueue(&paths, &ControlCommand::Pause).unwrap_err();
857        assert!(err.to_string().contains("refusing"), "{err}");
858        // Nothing was routed into the target tree.
859        assert!(!elsewhere.path().join("control").exists());
860    }
861
862    #[cfg(unix)]
863    #[test]
864    fn enqueue_refuses_a_symlinked_control_dir_without_touching_the_target() {
865        use std::os::unix::fs::symlink;
866        let tmp = TempDir::new().unwrap();
867        seed_mission(tmp.path(), "m-1", false);
868        let paths = MissionPaths::new(tmp.path(), "m-1");
869        let elsewhere = TempDir::new().unwrap();
870        symlink(elsewhere.path(), paths.control_dir()).unwrap();
871        let err = enqueue(&paths, &ControlCommand::Pause).unwrap_err();
872        assert!(err.to_string().contains("refusing"), "{err}");
873        // Nothing was written into the target dir.
874        assert!(std::fs::read_dir(elsewhere.path())
875            .unwrap()
876            .next()
877            .is_none());
878    }
879
880    #[test]
881    fn rapid_back_to_back_enqueues_drain_in_issue_order() {
882        // Failure-mode fixture for the 2026-07-23 CI flake: with a
883        // millisecond filename prefix, two commands enqueued in the same
884        // millisecond were drained in random-suffix order (resume sorted
885        // before pause and red the run). The nanosecond prefix must keep
886        // rapid back-to-back enqueues in issue order.
887        let tmp = TempDir::new().unwrap();
888        let paths = MissionPaths::new(tmp.path(), "m-1");
889        for cmd in [
890            ControlCommand::Pause,
891            ControlCommand::Resume,
892            ControlCommand::Pause,
893            ControlCommand::Resume,
894            ControlCommand::Pause,
895        ] {
896            enqueue(&paths, &cmd).unwrap();
897        }
898        let order: Vec<bool> = drain(&paths)
899            .unwrap()
900            .iter()
901            .map(|(_, cmd)| matches!(cmd, ControlCommand::Pause))
902            .collect();
903        assert_eq!(
904            order,
905            vec![true, false, true, false, true],
906            "rapid enqueues must drain in issue order, never random-suffix order"
907        );
908    }
909}
910
911#[cfg(test)]
912mod replay_tests {
913    use super::*;
914    use tempfile::TempDir;
915
916    /// A captured, correctly signed control file re-dropped after the engine
917    /// acknowledged it must not drain again (follow-up review F-1).
918    #[test]
919    fn an_acknowledged_control_file_cannot_be_replayed() {
920        let tmp = TempDir::new().unwrap();
921        let paths = MissionPaths::new(tmp.path(), "m-1");
922        let cmd = ControlCommand::ApproveGrant {
923            command: "cargo publish".to_string(),
924        };
925        let path = enqueue(&paths, &cmd).unwrap();
926        let captured = std::fs::read(&path).unwrap();
927
928        let drained = drain(&paths).unwrap();
929        assert_eq!(drained.len(), 1);
930        acknowledge(&paths, &path).unwrap();
931
932        std::fs::write(&path, &captured).unwrap();
933        let again = drain(&paths).unwrap();
934        assert!(
935            again.is_empty(),
936            "a replayed control file drained: {again:?}"
937        );
938        let bad = std::fs::read_dir(paths.control_dir())
939            .unwrap()
940            .flatten()
941            .filter(|e| e.file_name().to_string_lossy().ends_with(".bad"))
942            .count();
943        assert_eq!(bad, 1, "the replay must be quarantined");
944
945        // A fresh enqueue after the mark still works: the mark is a floor,
946        // not a lock.
947        let fresh = enqueue(&paths, &ControlCommand::Pause).unwrap();
948        assert!(
949            fresh.file_name().unwrap().to_string_lossy()
950                > path.file_name().unwrap().to_string_lossy()
951        );
952        assert_eq!(drain(&paths).unwrap().len(), 1);
953    }
954
955    /// The signature binds the file name: the same signed body under a newer
956    /// name does not verify.
957    #[test]
958    fn a_signed_body_moved_to_a_new_name_does_not_verify() {
959        let tmp = TempDir::new().unwrap();
960        let paths = MissionPaths::new(tmp.path(), "m-1");
961        let path = enqueue(&paths, &ControlCommand::Pause).unwrap();
962        let captured = std::fs::read(&path).unwrap();
963        std::fs::remove_file(&path).unwrap();
964        std::fs::write(
965            paths
966                .control_dir()
967                .join("99999999999999999999-ffffffff.json"),
968            &captured,
969        )
970        .unwrap();
971        assert!(drain(&paths).unwrap().is_empty());
972    }
973}