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
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