git.lucas.co / cce-secrets
secrets manager
git clone https://git.lucas.co/cce-secrets.git

commitfe4ecf10ae15a1242d71b502bdc778011e4c3a70
parent2ed3c67770
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 10:14
keyring sync: refuse a pass that would remove a tenth of the vault

Nothing bounded what one pass could remove. The vault is named, not
pinned by id, and op uses its default account. If a second account's
"Personal" vault became the default, the listing would hold entirely
different items, every synced entry would read as deleted in 1Password,
and the pass would hard-delete all ~378 of them from the keyring.

A pass whose deletions plus archives exceed a tenth of the synced
entries (never fewer than 5) now applies nothing and returns an error
naming the count. The daemon logs it and retries. A deliberate large
removal goes through once with `cce-keyring-sync sync
--allow-mass-delete`, after reviewing it with --dry-run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/bin/cce-keyring-sync/daemon.rs |  2 +-
 src/bin/cce-keyring-sync/main.rs   |  4 +++-
 src/bin/cce-keyring-sync/sync.rs   | 47 ++++++++++++++++++++++++++++++++++++++
 3 files changed, 51 insertions(+), 2 deletions(-)

diff --git a/src/bin/cce-keyring-sync/daemon.rs b/src/bin/cce-keyring-sync/daemon.rs
index db075e6..6b33244 100644
--- a/src/bin/cce-keyring-sync/daemon.rs
+++ b/src/bin/cce-keyring-sync/daemon.rs
@@ -57,7 +57,7 @@ pub async fn daemon(state_path: &std::path::Path) {
             TICK
         } else {
             let mut remote = OnePassword::new(&state.vault);
-            match sync_remote(&mut remote, state_path, &mut state, false).await {
+            match sync_remote(&mut remote, state_path, &mut state, false, false).await {
                 Ok(_) => {
                     dismissed = 0;
                     app_down = 0;
diff --git a/src/bin/cce-keyring-sync/main.rs b/src/bin/cce-keyring-sync/main.rs
index 4bf40a1..bc4f02a 100644
--- a/src/bin/cce-keyring-sync/main.rs
+++ b/src/bin/cce-keyring-sync/main.rs
@@ -178,6 +178,7 @@ pub(crate) fn write_state(state_path: &Path, state: &State) {
 async fn main() {
     let args: Vec<String> = std::env::args().skip(1).collect();
     let dry_run = args.iter().any(|a| a == "--dry-run");
+    let allow_mass_delete = args.iter().any(|a| a == "--allow-mass-delete");
     let vault_flag = args
         .iter()
         .position(|a| a == "--vault")
@@ -204,6 +205,7 @@ async fn main() {
         Some(c @ ("sync" | "status" | "adopt" | "daemon")) => c.to_string(),
         _ => {
             eprintln!("usage: cce-keyring-sync sync   [--dry-run]                  (one merge pass; raises its own Authorize dialog)");
+            eprintln!("       cce-keyring-sync sync   --allow-mass-delete          (let one pass remove more than a tenth of the synced items)");
             eprintln!("       cce-keyring-sync daemon                              (resident; what cce-keyring-sync.service runs)");
             eprintln!("       cce-keyring-sync adopt  [--dry-run] [--vault <name>]  (pair the keyring with 1Password, seed the base)");
             eprintln!("       cce-keyring-sync status");
@@ -235,7 +237,7 @@ async fn main() {
             // A one-shot pass; the daemon is the usual caller, and the flock
             // keeps the two apart.
             let mut remote = op::OnePassword::new(&state.vault);
-            if let Err(e) = sync::sync_remote(&mut remote, &state_path, &mut state, dry_run).await {
+            if let Err(e) = sync::sync_remote(&mut remote, &state_path, &mut state, dry_run, allow_mass_delete).await {
                 eprintln!("{e}");
                 std::process::exit(1);
             }
diff --git a/src/bin/cce-keyring-sync/sync.rs b/src/bin/cce-keyring-sync/sync.rs
index 945bd0c..fbee678 100644
--- a/src/bin/cce-keyring-sync/sync.rs
+++ b/src/bin/cce-keyring-sync/sync.rs
@@ -147,11 +147,30 @@ struct Local<'a> {
 /// One merge pass. Always writes the state file on a real run (with
 /// `last_result` set to the outcome, success or not) unless it could not
 /// even start. Returns the one-line summary, or the error.
+/// Removals one pass may make before it is refused: deletions from the
+/// keyring plus archives in 1Password, at most 10% of the synced entries
+/// and never fewer than this.
+const MASS_REMOVAL_FLOOR: usize = 5;
+
+/// Whether a pass removing `removals` items, with `synced` entries in the
+/// base, looks like a mistake rather than an edit.
+///
+/// Nothing else bounded it. The vault is named, not pinned by id, and `op`
+/// uses its default account, so a second account with its own "Personal"
+/// vault becoming the default would list entirely different items: every
+/// synced entry would read as deleted in 1Password, and the pass would
+/// hard-delete all of them from the keyring. People delete a few items at a
+/// time; a pass that would remove a tenth of everything stops and says so.
+pub fn is_mass_removal(removals: usize, synced: usize) -> bool {
+    removals > MASS_REMOVAL_FLOOR.max(synced / 10)
+}
+
 pub async fn sync_remote<I: Interchange>(
     remote: &mut I,
     state_path: &std::path::Path,
     state: &mut State,
     dry_run: bool,
+    allow_mass_removal: bool,
 ) -> Result<String, String> {
     let Some(_lock) = take_lock() else {
         return Err("another cce-keyring-sync is running".into());
@@ -323,6 +342,16 @@ pub async fn sync_remote<I: Interchange>(
         println!("{summary} (dry run — nothing changed; {fetches} fetched)");
         return Ok(summary);
     }
+    let removals = c("deleted") + c("archived");
+    if !allow_mass_removal && is_mass_removal(removals, state.entries.len()) {
+        return Err(format!(
+            "refusing a pass that would remove {removals} of {} synced items ({}) — nothing changed. \
+             If 1Password's default account or vault changed, fix that; if the removals are meant, \
+             run `cce-keyring-sync sync --dry-run` to review them, then `cce-keyring-sync sync --allow-mass-delete`",
+            state.entries.len(),
+            summary
+        ));
+    }
 
     // ---- apply ----
     // `next` starts as the old base and is rewritten entry by entry, so a
@@ -524,6 +553,24 @@ pub async fn sync_remote<I: Interchange>(
     result
 }
 
+#[cfg(test)]
+mod mass_removal_tests {
+    use super::is_mass_removal;
+
+    #[test]
+    fn a_pass_removing_a_tenth_of_the_vault_is_refused() {
+        // The live vault is ~378 entries: 37 removals pass, 38 stop.
+        assert!(!is_mass_removal(37, 378));
+        assert!(is_mass_removal(38, 378));
+        // Every entry reading as gone — the wrong-account case.
+        assert!(is_mass_removal(378, 378));
+        // Small vaults still allow a handful.
+        assert!(!is_mass_removal(5, 12));
+        assert!(is_mass_removal(6, 12));
+        assert!(!is_mass_removal(0, 0));
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;