Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 91 additions & 39 deletions crosslink/src/commands/locks_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,10 +420,23 @@ pub fn sync_cmd(crosslink_dir: &Path, db: &Database) -> Result<()> {
}
}
SignatureVerification::Unsigned { commit } => {
println!(
"Locks synced. WARNING: Latest commit ({}) is NOT signed.",
&commit[..7.min(commit.len())]
);
// Suppress the warning for bootstrap commits (init creates locks.json
// before signing is configured, so it's always unsigned).
let is_bootstrap = sync
.commit_message(commit)
.map(|msg| crate::sync::bootstrap::is_bootstrap_message(&msg))
.unwrap_or(false);
if is_bootstrap {
println!(
"Locks synced (commit {} is an unsigned bootstrap commit).",
&commit[..7.min(commit.len())]
);
} else {
println!(
"Locks synced. WARNING: Latest commit ({}) is NOT signed.",
&commit[..7.min(commit.len())]
);
}
}
SignatureVerification::Invalid { commit, reason } => {
println!(
Expand All @@ -448,55 +461,94 @@ pub fn sync_cmd(crosslink_dir: &Path, db: &Database) -> Result<()> {
}
}

// Signing enforcement check
// Signing enforcement check — bootstrap-aware (#644)
let enforcement = read_signing_enforcement(crosslink_dir);
if enforcement != "disabled" {
let results = sync.verify_recent_commits(5)?;
let unsigned: Vec<_> = results
.iter()
.filter(|(_, v)| matches!(v, SignatureVerification::Unsigned { .. }))
.collect();
let invalid: Vec<_> = results
.iter()
.filter(|(_, v)| matches!(v, SignatureVerification::Invalid { .. }))
.collect();

if !unsigned.is_empty() || !invalid.is_empty() {
let msg = format!(
"{} unsigned, {} invalid signature(s) in last {} commit(s)",
unsigned.len(),
invalid.len(),
results.len()
);
let bootstrap = crate::sync::bootstrap::read_bootstrap_state(sync.cache_path());

if bootstrap.as_ref().map(|b| b.status.as_str()) == Some("pending") {
// Bootstrap incomplete — enforcement cannot work yet because
// bootstrap commits are inherently unsigned by design.
if enforcement == "enforced" {
anyhow::bail!("Signing enforcement FAILED: {msg}");
anyhow::bail!(
"Hub bootstrap incomplete \u{2014} signing enforcement cannot proceed.\n\
\n\
Bootstrap commits are inherently unsigned. To complete setup:\n\
1. Run `crosslink trust pending` to see keys awaiting approval\n\
2. Run `crosslink trust approve <agent-id>` for each agent\n\
\n\
This establishes the trust chain and enables enforcement."
);
}
// audit mode
println!("Signing audit: {msg}");
} else if !results.is_empty() {
println!(
"Signing audit: all {} recent commit(s) are signed.",
results.len()
"Signing audit: bootstrap pending \u{2014} unsigned bootstrap commit(s) expected."
);
}

// Per-entry signature verification
let (verified, failed, entry_unsigned) = sync.verify_entry_signatures()?;
let total_entries = verified + failed + entry_unsigned;
if total_entries > 0 {
if failed > 0 {
} else {
// "complete" or no bootstrap state (old repo): full enforcement.
let results = sync.verify_recent_commits(5)?;

// Filter out bootstrap commits identified by their deterministic
// commit message prefix (init + key publication).
let is_bootstrap = |hash: &str| -> bool {
if bootstrap.is_none() {
return false; // old repo, no filtering
}
sync.commit_message(hash)
.map(|msg| crate::sync::bootstrap::is_bootstrap_message(&msg))
.unwrap_or(false)
};

let unsigned: Vec<_> = results
.iter()
.filter(|(hash, v)| {
matches!(v, SignatureVerification::Unsigned { .. }) && !is_bootstrap(hash)
})
.collect();
let invalid: Vec<_> = results
.iter()
.filter(|(hash, v)| {
matches!(v, SignatureVerification::Invalid { .. }) && !is_bootstrap(hash)
})
.collect();

if !unsigned.is_empty() || !invalid.is_empty() {
let msg = format!(
"{verified} verified, {failed} FAILED, {entry_unsigned} unsigned entry signature(s)"
"{} unsigned, {} invalid signature(s) in last {} commit(s)",
unsigned.len(),
invalid.len(),
results.len()
);
if enforcement == "enforced" {
anyhow::bail!("Entry signing enforcement FAILED: {msg}");
anyhow::bail!("Signing enforcement FAILED: {msg}");
}
println!("Entry signing audit: {msg}");
} else if verified > 0 {
// audit mode
println!("Signing audit: {msg}");
} else if !results.is_empty() {
println!(
"Entry signing audit: {verified} verified, {entry_unsigned} unsigned entry signature(s)."
"Signing audit: all {} recent commit(s) are signed.",
results.len()
);
}

// Per-entry signature verification
let (verified, failed, entry_unsigned) = sync.verify_entry_signatures()?;
let total_entries = verified + failed + entry_unsigned;
if total_entries > 0 {
if failed > 0 {
let msg = format!(
"{verified} verified, {failed} FAILED, {entry_unsigned} unsigned entry signature(s)"
);
if enforcement == "enforced" {
anyhow::bail!("Entry signing enforcement FAILED: {msg}");
}
println!("Entry signing audit: {msg}");
} else if verified > 0 {
println!(
"Entry signing audit: {verified} verified, {entry_unsigned} unsigned entry signature(s)."
);
}
}
}
}

Expand Down
21 changes: 21 additions & 0 deletions crosslink/src/commands/trust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@ pub fn approve(crosslink_dir: &Path, agent_id: &str) -> Result<()> {
let approval_path = approvals_dir.join(format!("{agent_id}.json"));
std::fs::write(&approval_path, serde_json::to_string_pretty(&approval)?)?;

// Complete bootstrap if pending — the first approval establishes the
// trust chain, enabling signing enforcement (#644).
let bootstrap_completed =
if let Some(state) = crate::sync::bootstrap::read_bootstrap_state(cache) {
if state.status == "pending" {
crate::sync::bootstrap::complete_bootstrap(cache)?;
true
} else {
false
}
} else {
false
};

// Commit and push
commit_trust_change(
cache,
Expand All @@ -106,6 +120,9 @@ pub fn approve(crosslink_dir: &Path, agent_id: &str) -> Result<()> {
} else {
println!("Approved agent '{agent_id}' (principal: {principal})");
}
if bootstrap_completed {
println!("Bootstrap complete — signing enforcement is now active.");
}
Ok(())
}

Expand Down Expand Up @@ -306,6 +323,10 @@ fn commit_trust_change_impl(
};

git(&["add", "trust/"])?;
// Stage bootstrap state if updated by this trust change (#644)
if cache_dir.join("meta").join("bootstrap.json").exists() {
let _ = git(&["add", "meta/bootstrap.json"]);
}

if unsigned {
git(&["-c", "commit.gpgsign=false", "commit", "-m", message])?;
Expand Down
152 changes: 152 additions & 0 deletions crosslink/src/sync/bootstrap.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;

/// Tracks the one-time bootstrap phase of a hub branch.
///
/// New hubs require inherently unsigned commits (init, key publication)
/// before signing can be configured. This state lets enforcement
/// distinguish the bootstrap phase from normal operation.
///
/// Lifecycle:
/// 1. `init_cache()` writes `status: "pending"` in the first commit.
/// 2. `crosslink trust approve` sets `status: "complete"`.
/// 3. Enforcement blocks with actionable guidance when pending; filters
/// bootstrap commits (identified by message prefix) when complete.
///
/// Stored at `meta/bootstrap.json` on the hub branch.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootstrapState {
/// `"pending"` during setup, `"complete"` after first `trust approve`.
pub status: String,
/// ISO 8601 timestamp when bootstrap was completed.
#[serde(skip_serializing_if = "Option::is_none")]
pub completed_at: Option<String>,
}

/// Commit message prefixes that identify inherently-unsigned bootstrap commits.
/// These are generated by `init_cache()` and `ensure_agent_key_published()`.
const BOOTSTRAP_MESSAGE_PREFIXES: &[&str] = &[
"Initialize crosslink/hub branch",
"trust: publish key for agent",
"sync: auto-stage dirty hub state",
"bootstrap: register agent",
];

/// Check whether a commit message identifies a bootstrap commit.
pub fn is_bootstrap_message(message: &str) -> bool {
BOOTSTRAP_MESSAGE_PREFIXES
.iter()
.any(|prefix| message.starts_with(prefix))
}

/// Read bootstrap state from `meta/bootstrap.json` in the cache directory.
///
/// Returns `None` if the file does not exist (backwards compat with old repos)
/// or cannot be parsed.
pub fn read_bootstrap_state(cache_dir: &Path) -> Option<BootstrapState> {
let path = cache_dir.join("meta").join("bootstrap.json");
let content = std::fs::read_to_string(&path).ok()?;
match serde_json::from_str(&content) {
Ok(state) => Some(state),
Err(e) => {
tracing::warn!("failed to parse bootstrap.json: {e}");
None
}
}
}

/// Write bootstrap state to `meta/bootstrap.json`.
pub fn write_bootstrap_state(cache_dir: &Path, state: &BootstrapState) -> Result<()> {
let meta_dir = cache_dir.join("meta");
std::fs::create_dir_all(&meta_dir)
.with_context(|| format!("Failed to create {}", meta_dir.display()))?;
let path = meta_dir.join("bootstrap.json");
let json =
serde_json::to_string_pretty(state).context("Failed to serialize bootstrap state")?;
std::fs::write(&path, json).with_context(|| format!("Failed to write {}", path.display()))?;
Ok(())
}

/// Mark bootstrap as complete. Called by `trust approve` on first approval.
pub fn complete_bootstrap(cache_dir: &Path) -> Result<()> {
let mut state = read_bootstrap_state(cache_dir).unwrap_or_else(|| BootstrapState {
status: "pending".to_string(),
completed_at: None,
});
state.status = "complete".to_string();
state.completed_at = Some(chrono::Utc::now().to_rfc3339());
write_bootstrap_state(cache_dir, &state)
}

#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;

#[test]
fn test_read_missing_returns_none() {
let dir = tempdir().unwrap();
assert!(read_bootstrap_state(dir.path()).is_none());
}

#[test]
fn test_write_read_roundtrip() {
let dir = tempdir().unwrap();
let state = BootstrapState {
status: "pending".to_string(),
completed_at: None,
};
write_bootstrap_state(dir.path(), &state).unwrap();
let loaded = read_bootstrap_state(dir.path()).unwrap();
assert_eq!(loaded.status, "pending");
assert!(loaded.completed_at.is_none());
}

#[test]
fn test_complete_bootstrap() {
let dir = tempdir().unwrap();
write_bootstrap_state(
dir.path(),
&BootstrapState {
status: "pending".to_string(),
completed_at: None,
},
)
.unwrap();
complete_bootstrap(dir.path()).unwrap();
let state = read_bootstrap_state(dir.path()).unwrap();
assert_eq!(state.status, "complete");
assert!(state.completed_at.is_some());
}

#[test]
fn test_complete_without_prior_state() {
let dir = tempdir().unwrap();
complete_bootstrap(dir.path()).unwrap();
let state = read_bootstrap_state(dir.path()).unwrap();
assert_eq!(state.status, "complete");
assert!(state.completed_at.is_some());
}

#[test]
fn test_malformed_json_returns_none() {
let dir = tempdir().unwrap();
let meta = dir.path().join("meta");
std::fs::create_dir_all(&meta).unwrap();
std::fs::write(meta.join("bootstrap.json"), "not json").unwrap();
assert!(read_bootstrap_state(dir.path()).is_none());
}

#[test]
fn test_is_bootstrap_message() {
assert!(is_bootstrap_message("Initialize crosslink/hub branch"));
assert!(is_bootstrap_message("trust: publish key for agent 'foo'"));
assert!(is_bootstrap_message(
"sync: auto-stage dirty hub state (recovery)"
));
assert!(is_bootstrap_message("bootstrap: register agent 'abc'"));
assert!(!is_bootstrap_message("some random commit"));
assert!(!is_bootstrap_message("fix: a real commit"));
}
}
10 changes: 10 additions & 0 deletions crosslink/src/sync/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,16 @@ impl SyncManager {
// Exclude runtime files from tracking before first commit (#528)
self.ensure_hub_gitignore()?;

// Write initial bootstrap state so it's included in the first commit.
// This marks the hub as being in the bootstrap phase (#644).
super::bootstrap::write_bootstrap_state(
&self.cache_dir,
&super::bootstrap::BootstrapState {
status: "pending".to_string(),
completed_at: None,
},
)?;

// Commit the initial state so the branch has at least one commit.
// Without this, `git log` and other commands fail on the empty orphan.
self.git_in_cache(&["add", "-A"])?;
Expand Down
6 changes: 6 additions & 0 deletions crosslink/src/sync/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@ impl SyncManager {
Ok(output)
}

/// Get the subject line of a commit in the cache worktree.
pub fn commit_message(&self, commit: &str) -> Result<String> {
let output = self.git_in_cache(&["log", "-1", "--format=%s", commit])?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

pub(super) fn git_in_cache(&self, args: &[&str]) -> Result<std::process::Output> {
let output = Command::new("git")
.current_dir(&self.cache_dir)
Expand Down
1 change: 1 addition & 0 deletions crosslink/src/sync/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod bootstrap;
mod cache;
mod core;
mod heartbeats;
Expand Down
Loading
Loading