Unlink dispatch.json after read instead of leaving secrets on disk
`dispatch.json` carries plaintext secrets at mode 0600 and was never
removed — the file persisted in the run directory until the retention
sweep fired, which defeats the "0600 is the line of defence" framing.
Now `quire-ci` unlinks the file as soon as `load_dispatch` reads the
bytes into memory, and the orchestrator best-effort unlinks after the
subprocess exits as a safety net for paths where quire-ci didn't get
that far (spawn failure, arg parse rejection, panic before read).

Assisted-by: Claude Opus 4.7 via Claude Code
change
commit bcd3b48716895b46bf574f2165cda51ec0cd9711
author Claude <noreply@anthropic.com>
date
parent lllqnvrv
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 11a9a2e..9e05335 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -302,6 +302,12 @@ fn placeholder_meta() -> RunMeta {
 
 /// Read and parse the dispatch file the orchestrator wrote before
 /// spawning. Wraps revealed secret values back into `SecretString`.
+///
+/// Unlinks the file as soon as the bytes are in memory — secrets only
+/// need to live on disk for the moment between `write_dispatch` and
+/// this read, and getting them off disk early limits the blast radius
+/// of a later panic or crash leaving a 0600 file behind.
+///
 /// The Sentry handoff, when present, carries the DSN and the
 /// orchestrator's trace id — the 0600 dispatch file is the line of
 /// defense for both.
@@ -318,6 +324,15 @@ fn load_dispatch(
     use quire_core::secret::SecretString;
 
     let bytes = fs_err::read(path).into_diagnostic()?;
+    if let Err(e) = fs_err::remove_file(path) {
+        // Don't abort — the bytes are already loaded and the server
+        // will best-effort unlink after we exit. But this is a
+        // security-relevant cleanup, so it's worth surfacing.
+        eprintln!(
+            "warning: failed to remove dispatch file {}: {e}",
+            path.display()
+        );
+    }
     let dispatch: Dispatch = serde_json::from_slice(&bytes).into_diagnostic()?;
     let secrets = dispatch
         .secrets
@@ -489,4 +504,30 @@ mod tests {
         };
         assert_eq!(path, PathBuf::from("/tmp/run.jsonl"));
     }
+
+    #[test]
+    fn load_dispatch_unlinks_after_read() {
+        use quire_core::ci::dispatch::Dispatch;
+
+        let dir = tempfile::tempdir().expect("tempdir");
+        let path = dir.path().join("dispatch.json");
+        let dispatch = Dispatch {
+            meta: RunMeta {
+                sha: "0".repeat(40),
+                r#ref: "HEAD".to_string(),
+                pushed_at: jiff::Timestamp::now(),
+            },
+            git_dir: PathBuf::from("/tmp/repo.git"),
+            secrets: HashMap::from([("token".to_string(), "shh".to_string())]),
+            sentry: None,
+        };
+        fs_err::write(&path, serde_json::to_vec(&dispatch).unwrap()).expect("write");
+
+        let (git_dir, meta, secrets, sentry) = load_dispatch(&path).expect("load");
+        assert!(!path.exists(), "dispatch file should be removed after read");
+        assert_eq!(git_dir, PathBuf::from("/tmp/repo.git"));
+        assert_eq!(meta.r#ref, "HEAD");
+        assert_eq!(secrets.len(), 1);
+        assert!(sentry.is_none());
+    }
 }
diff --git a/quire-core/src/ci/dispatch.rs b/quire-core/src/ci/dispatch.rs
index d0f0353..30981ba 100644
--- a/quire-core/src/ci/dispatch.rs
+++ b/quire-core/src/ci/dispatch.rs
@@ -9,7 +9,11 @@
 //! values.
 //!
 //! The file contains live secret values; callers must restrict
-//! permissions (mode 0600 on Unix) before writing.
+//! permissions (mode 0600 on Unix) before writing. The file is a
+//! one-shot handoff: `quire-ci` unlinks it as soon as it has read
+//! the bytes into memory, and the orchestrator best-effort unlinks
+//! after the subprocess exits as a safety net. Plaintext secrets
+//! should never persist in the run directory.
 
 use std::collections::HashMap;
 use std::path::PathBuf;
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 5ee9b4e..9118552 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -316,6 +316,21 @@ impl Run {
                 source,
             })?;
 
+        // quire-ci unlinks the dispatch file after `load_dispatch`;
+        // this is a best-effort safety net for paths where it didn't
+        // get that far (spawn failed mid-exec, arg parsing rejected
+        // input, panic before read). `NotFound` is the expected case.
+        if let Err(e) = fs_err::remove_file(&dispatch_path)
+            && e.kind() != std::io::ErrorKind::NotFound
+        {
+            tracing::warn!(
+                run_id = %self.id,
+                path = %dispatch_path.display(),
+                error = %e,
+                "failed to remove dispatch file after run"
+            );
+        }
+
         // Ingest events whether or not the run succeeded — partial
         // results are still useful in the UI. A failure to read or
         // parse the file goes to the log but doesn't mask the run's