crosslink 0.9.0-beta.1

A synced issue tracker CLI for multi-agent AI development
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
use anyhow::{bail, Context, Result};
use chrono::Utc;
use std::path::{Path, PathBuf};
use std::process::Command;

use super::core::SyncManager;
use super::SignatureVerification;
use crate::identity::{AgentConfig, AgentRole};
use crate::signing;

/// Resolve the user's home directory from environment variables.
///
/// Uses `$HOME` on Unix and `$USERPROFILE` on Windows.
fn home_dir() -> Option<PathBuf> {
    #[cfg(target_os = "windows")]
    {
        std::env::var("USERPROFILE").ok().map(PathBuf::from)
    }
    #[cfg(not(target_os = "windows"))]
    {
        std::env::var("HOME").ok().map(PathBuf::from)
    }
}

/// Expand a leading `~/` or bare `~` in a path string against the user's home.
///
/// Returns the input unchanged if there's no tilde or home cannot be resolved.
fn expand_tilde(path: &str) -> PathBuf {
    if let Some(rest) = path.strip_prefix("~/") {
        return home_dir().map_or_else(
            || {
                tracing::warn!(
                    "tilde expansion failed: cannot determine home directory for '{}'",
                    path
                );
                PathBuf::from(path)
            },
            |home| home.join(rest),
        );
    }
    if path == "~" {
        return home_dir().unwrap_or_else(|| PathBuf::from(path));
    }
    PathBuf::from(path)
}

/// Resolve an agent's SSH private key path from the relative form stored in
/// `agent.json` (`ssh_key_path`).
///
/// GH#610: new agents store their key under the *main repo's*
/// `.crosslink/keys/` so it survives `git worktree remove` of a kickoff
/// agent worktree. Legacy agents (created before that change) have their
/// key inside the worktree's own `.crosslink/keys/`. Try the host path
/// first; fall back to the worktree-local path so existing deployments
/// keep signing until the legacy worktree is cleaned up.
pub(super) fn resolve_agent_key(worktree_crosslink_dir: &Path, rel_key: &str) -> PathBuf {
    let host = crate::signing::host_crosslink_dir(worktree_crosslink_dir);
    let host_path = host.join(rel_key);
    if host_path.exists() {
        return host_path;
    }
    worktree_crosslink_dir.join(rel_key)
}

/// Best guess whether a `user.signingkey` value is a filesystem path rather
/// than literal key material (e.g. an inline `ssh-ed25519 AAAA...` line).
///
/// Only paths need existence validation; literal key material has nothing to
/// check against the filesystem.
fn signingkey_value_is_path(value: &str) -> bool {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        return false;
    }
    // Literal SSH / PGP key material — not a path.
    if trimmed.starts_with("ssh-")
        || trimmed.starts_with("ecdsa-")
        || trimmed.starts_with("sk-")
        || trimmed.starts_with("-----BEGIN")
    {
        return false;
    }
    true
}

impl SyncManager {
    /// Configure SSH signing in the hub cache worktree.
    ///
    /// **Role-aware signing** (#718):
    ///
    /// - Main / driver workspaces (`agent.json.role == Driver` or
    ///   no `agent.json`): sign hub commits with the DRIVER's SSH
    ///   signing key (from the main repo's `user.signingkey`).
    ///   That's the key the human registered on their GitHub
    ///   account; commits verify end-to-end.
    /// - Subagent worktrees (`agent.json.role == Agent`): sign with
    ///   the agent's SSH key. That's the kickoff/swarm-scoped
    ///   identity; commits attribute to the agent and verify
    ///   locally via `allowed_signers`. GitHub shows them
    ///   unverified unless the agent pub key has been registered
    ///   there too.
    ///
    /// The agent's identity always lives in `agent.json` (who
    /// initiated the action). Only the SIGNATURE bytes differ by
    /// role — and critically, we never sign a driver-workspace's
    /// commit with an agent key that GitHub doesn't know.
    ///
    /// # Errors
    /// Returns an error if configuring git signing fails.
    pub fn configure_signing(&self, crosslink_dir: &Path) -> Result<()> {
        if !self.cache_dir.exists() {
            return Ok(());
        }

        // Ensure allowed_signers file always exists so git's
        // verify-commit correctly classifies signed commits,
        // whatever key ends up signing.
        let allowed_signers = self.cache_dir.join("trust").join("allowed_signers");
        if !allowed_signers.exists() {
            signing::AllowedSigners::default().save(&allowed_signers)?;
        }

        // Determine whether this is a driver-owned workspace or an
        // agent's subagent worktree. The role lives in agent.json;
        // if agent.json is missing we default to driver (the main-
        // repo case is the common one).
        let is_agent_worktree =
            AgentConfig::load(crosslink_dir)?.is_some_and(|c| matches!(c.role, AgentRole::Agent));

        if is_agent_worktree {
            // Subagent worktree — sign with the agent's key so the
            // attribution is distinct.
            if let Some(agent) = AgentConfig::load(crosslink_dir)? {
                if let (Some(rel_key), Some(_)) = (&agent.ssh_key_path, &agent.ssh_fingerprint) {
                    let private_key = resolve_agent_key(&self.crosslink_dir, rel_key);
                    if private_key.exists() {
                        signing::configure_git_ssh_signing(
                            &self.cache_dir,
                            &private_key,
                            Some(&allowed_signers),
                        )?;
                        register_active_key_as_trusted(
                            &self.cache_dir,
                            crosslink_dir,
                            &private_key,
                            &allowed_signers,
                        )?;
                        return Ok(());
                    }
                }
            }
            // Agent worktree but key missing — fall through to
            // driver key as a recovery path; better a verified
            // driver commit than an unsigned one.
        }

        // Driver-owned workspace — or agent worktree missing its
        // key. Prefer the driver's SSH signing key.
        if let Some(driver_key) = self.driver_signing_key() {
            if driver_key.exists() {
                signing::configure_git_ssh_signing(
                    &self.cache_dir,
                    &driver_key,
                    Some(&allowed_signers),
                )?;
                register_active_key_as_trusted(
                    &self.cache_dir,
                    crosslink_dir,
                    &driver_key,
                    &allowed_signers,
                )?;
                return Ok(());
            }
            tracing::warn!(
                "driver signing key configured but not found at {}; falling back to agent key",
                driver_key.display()
            );
        }

        // Driver key unavailable — fall back to whatever key agent.json
        // knows about, so hub commits still sign even when the operator
        // hasn't set `user.signingkey` in the main repo.
        if let Some(agent) = AgentConfig::load(crosslink_dir)? {
            if let (Some(rel_key), Some(_)) = (&agent.ssh_key_path, &agent.ssh_fingerprint) {
                let private_key = resolve_agent_key(&self.crosslink_dir, rel_key);
                if private_key.exists() {
                    signing::configure_git_ssh_signing(
                        &self.cache_dir,
                        &private_key,
                        Some(&allowed_signers),
                    )?;
                    register_active_key_as_trusted(
                        &self.cache_dir,
                        crosslink_dir,
                        &private_key,
                        &allowed_signers,
                    )?;
                    return Ok(());
                }
            }
        }

        // Nothing usable — disable signing so commits still land.
        tracing::warn!(
            "no usable signing key for {} workspace; disabling hub-commit signing",
            if is_agent_worktree { "agent" } else { "driver" }
        );
        signing::disable_git_signing(&self.cache_dir)?;
        Ok(())
    }

    /// Resolve the driver's SSH signing key path from the main repo's
    /// git config. Returns the expanded absolute path if
    /// `user.signingkey` points at an existing file; `None` if unset,
    /// empty, or unparsable.
    fn driver_signing_key(&self) -> Option<std::path::PathBuf> {
        let output = Command::new("git")
            .current_dir(&self.repo_root)
            .args(["config", "user.signingkey"])
            .output()
            .ok()?;
        if !output.status.success() {
            return None;
        }
        let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
        if raw.is_empty() {
            return None;
        }
        Some(expand_tilde(&raw))
    }

    /// Fall back to the driver's signing key when the agent key is missing.
    ///
    /// Reads `user.signingkey` from the main repo's git config. If found and
    /// the key file exists, configures the hub cache worktree to use it.
    /// If no driver key is found, disables signing so commits can proceed
    /// unsigned rather than failing fatally.
    pub(super) fn fallback_to_driver_signing(&self) -> Result<()> {
        // Try to read the driver's signing key from the main repo config
        let output = Command::new("git")
            .current_dir(&self.repo_root)
            .args(["config", "user.signingkey"])
            .output();

        let driver_key = output.ok().and_then(|o| {
            if o.status.success() {
                let key = String::from_utf8_lossy(&o.stdout).trim().to_string();
                if key.is_empty() {
                    None
                } else {
                    Some(key)
                }
            } else {
                None
            }
        });

        if let Some(key_path) = driver_key {
            let expanded = expand_tilde(&key_path);

            if expanded.exists() {
                tracing::info!(
                    "agent key missing, falling back to driver signing key: {}",
                    expanded.display()
                );
                signing::configure_git_ssh_signing(&self.cache_dir, &expanded, None)?;
            } else {
                tracing::warn!(
                    "agent key missing and driver key not found at {}, disabling signing",
                    expanded.display()
                );
                signing::disable_git_signing(&self.cache_dir)?;
            }
        } else {
            tracing::warn!(
                "agent key missing and no driver signing key configured, disabling signing"
            );
            signing::disable_git_signing(&self.cache_dir)?;
        }

        Ok(())
    }

    /// Self-heal a stale `user.signingkey` in the hub-cache worktree config.
    ///
    /// Reads the effective `user.signingkey` for `cache_dir`. If the value is
    /// a filesystem path that no longer exists (typical when an agent worktree
    /// containing the key was deleted — see GH #565), delegates to
    /// [`Self::fallback_to_driver_signing`] to rewrite `config.worktree` with
    /// the driver key (or disable signing if the driver has no key either).
    ///
    /// Returns `Ok(true)` if a repair was performed, `Ok(false)` when nothing
    /// needed repairing. Designed to be called as a best-effort preamble to
    /// every commit in the cache worktree: cheap on the happy path (one
    /// `git config` read plus a single `Path::exists()` check), self-healing
    /// on the sad path so future syncs succeed without manual intervention.
    ///
    /// Skips validation for literal key material (`ssh-ed25519 AAAA...`,
    /// `-----BEGIN ...`) that git accepts inline alongside file paths.
    ///
    /// # Errors
    ///
    /// Returns an error only if the fallback rewrite itself fails. A read
    /// failure (no signing configured, git missing, etc.) is treated as
    /// "nothing to repair" and returns `Ok(false)`.
    pub fn repair_stale_signingkey(&self) -> Result<bool> {
        if !self.cache_dir.exists() {
            return Ok(false);
        }

        let output = Command::new("git")
            .current_dir(&self.cache_dir)
            .args(["config", "user.signingkey"])
            .output();

        let Ok(output) = output else { return Ok(false) };
        if !output.status.success() {
            return Ok(false); // No signingkey configured — nothing to repair.
        }

        let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
        if !signingkey_value_is_path(&value) {
            return Ok(false); // Literal key material or empty — not our problem.
        }

        let expanded = expand_tilde(&value);
        if expanded.exists() {
            return Ok(false); // Path still valid — no repair needed.
        }

        tracing::warn!(
            "hub-cache user.signingkey points at missing file '{}' \
             (agent worktree likely deleted) — repairing (GH #565)",
            expanded.display()
        );
        self.fallback_to_driver_signing()?;
        Ok(true)
    }

    /// Ensure the agent's public key is published to `trust/keys/` on the hub.
    ///
    /// During `agent init`, key publishing is skipped if the hub cache doesn't
    /// exist yet. This method re-checks and publishes the key if needed, using
    /// an unsigned commit to avoid the chicken-and-egg problem where signing
    /// must be configured before the key can be published.
    ///
    /// Safe to call multiple times — no-ops if the key is already published.
    ///
    /// # Accepted risk: unsigned key-publication commit
    ///
    /// The commit that publishes the agent's public key is intentionally
    /// unsigned (`commit.gpgsign=false`). This is a bootstrapping trade-off:
    /// the signing key cannot be verified until it is published, so the
    /// publication commit itself cannot be signed by the key it publishes.
    /// Subsequent commits from this agent will be signed normally. Auditors
    /// can verify the key-publication commit via the git history (the key
    /// file hash is deterministic given the public key content).
    ///
    /// # Errors
    ///
    /// Returns an error if loading agent config, writing the key file, or committing fails.
    pub fn ensure_agent_key_published(&self, crosslink_dir: &Path) -> Result<bool> {
        if !self.cache_dir.exists() {
            return Ok(false);
        }

        let Some(agent) = AgentConfig::load(crosslink_dir)? else {
            return Ok(false);
        };

        let Some(public_key) = agent.ssh_public_key.clone() else {
            return Ok(false);
        };

        let key_file = self
            .cache_dir
            .join("trust")
            .join("keys")
            .join(format!("{}.pub", agent.agent_id));

        if key_file.exists() {
            return Ok(false); // Already published
        }

        // Publish the key using an unsigned commit to avoid the signing
        // chicken-and-egg: we need to publish before signing is configured.
        let keys_dir = self.cache_dir.join("trust").join("keys");
        std::fs::create_dir_all(&keys_dir)?;
        std::fs::write(&key_file, format!("{public_key}\n"))?;

        self.git_in_cache(&["add", "trust/"])?;
        // Use -c commit.gpgsign=false to bypass signing for key publishing
        let output = Command::new("git")
            .current_dir(&self.cache_dir)
            .args([
                "-c",
                "commit.gpgsign=false",
                "commit",
                "-m",
                &format!("trust: publish key for agent '{}'", agent.agent_id),
            ])
            .output()
            .context("Failed to commit key publication")?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            if !stderr.contains("nothing to commit") {
                bail!("git commit for key publication failed: {stderr}");
            }
        }

        Ok(true)
    }

    /// Read the SSH `allowed_signers` trust store from the cache.
    ///
    /// # Errors
    ///
    /// Returns an error if the allowed signers file cannot be read or parsed.
    pub fn read_allowed_signers(&self) -> Result<signing::AllowedSigners> {
        let path = self.cache_dir.join("trust").join("allowed_signers");
        signing::AllowedSigners::load(&path)
    }

    /// Verify a single commit's signature, returning a `SignatureVerification`.
    ///
    /// Shared implementation used by both `verify_recent_commits` and
    /// `verify_locks_signature` to avoid duplicated verification logic.
    fn verify_commit_signature(&self, commit: &str) -> Result<SignatureVerification> {
        let verify = Command::new("git")
            .current_dir(&self.cache_dir)
            .args(["verify-commit", "--raw", commit])
            .output()
            .context("Failed to run git verify-commit")?;

        let stderr = String::from_utf8_lossy(&verify.stderr);

        if verify.status.success() {
            Ok(SignatureVerification::Valid)
        } else if stderr.contains("NODATA")
            || stderr.contains("no signature")
            || stderr.is_empty()
            || stderr.contains("allowedSignersFile needs to be configured")
        {
            Ok(SignatureVerification::Unsigned)
        } else {
            Ok(SignatureVerification::Invalid)
        }
    }

    /// Verify the signature on the latest commit that touched locks.json.
    ///
    /// Handles both SSH and GPG signatures via `signing::parse_verify_output`.
    ///
    /// # Errors
    ///
    /// Returns an error if git log or signature verification commands fail.
    pub fn verify_locks_signature(&self) -> Result<SignatureVerification> {
        // Get the commit that last touched locks.json
        let output = self.git_in_cache(&["log", "-1", "--format=%H", "--", "locks.json"])?;
        let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();

        if commit.is_empty() {
            return Ok(SignatureVerification::NoCommits);
        }

        self.verify_commit_signature(&commit)
    }
}

/// Ensure the public key paired with `private_key_path` is registered in
/// `allowed_signers`. Idempotent: a no-op when the key is already trusted.
///
/// When a new entry is appended, the file is saved and an **unsigned**
/// commit is made in the cache worktree. The commit must be unsigned
/// because the freshly-registered key may not yet be present in any
/// pre-existing commit's view of `allowed_signers`, so signing this
/// commit with it would fail `git verify-commit` (the chicken-and-egg
/// `publish_agent_key` also navigates). Once this commit lands,
/// subsequent commits signed with the same key verify cleanly.
///
/// GH#585: before this call existed, only `crosslink trust approve
/// <agent>` ever wrote to `allowed_signers`. The driver's own signing
/// key (selected by `configure_signing`) was never registered, so every
/// signed hub commit out of a driver workspace failed verification.
///
/// GH#738: when this function actually adds a new entry while the hub is
/// still in `bootstrap.status = "pending"`, the registration *is* the
/// trust-establishment event — morally identical to running `crosslink
/// trust approve` on the workspace's own key — so we also flip bootstrap
/// to `"complete"` atomically in the same unsigned commit. Without this,
/// `trust pending` reports nothing pending (because the key is already
/// trusted by self-registration), and the bootstrap state would remain
/// "pending" forever, blocking signing enforcement.
///
/// Returns `Ok(true)` when an entry was added (and, by implication, when
/// bootstrap may have been completed). `Ok(false)` when the key was
/// already trusted under some principal.
fn register_active_key_as_trusted(
    cache_dir: &Path,
    crosslink_dir: &Path,
    private_key_path: &Path,
    allowed_signers_path: &Path,
) -> Result<bool> {
    use crate::signing::{AllowedSignerEntry, AllowedSigners};

    // Resolve the public-key companion file (SSH convention: <key>.pub).
    let public_key_path = with_pub_extension(private_key_path);
    let public_key = match crate::signing::read_public_key(&public_key_path) {
        Ok(k) => k,
        Err(e) => {
            tracing::debug!(
                "skipping allowed_signers self-registration: cannot read pubkey at {}: {e}",
                public_key_path.display()
            );
            return Ok(false);
        }
    };

    let mut signers = AllowedSigners::load(allowed_signers_path)?;
    if signers.contains_key(&public_key) {
        return Ok(false); // Already trusted under some principal — no-op.
    }

    // Pick a principal: prefer the agent.json identity for visibility,
    // fall back to a generic role+host label when none is configured.
    let principal = AgentConfig::load(crosslink_dir)?.map_or_else(
        || "driver@crosslink".to_string(),
        |c| format!("{}@crosslink", c.agent_id),
    );

    signers.add_entry(AllowedSignerEntry {
        principal: principal.clone(),
        public_key,
        metadata_comment: Some(format!(
            "self-registered as workspace signing key at {}",
            Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
        )),
    });
    signers.save(allowed_signers_path)?;

    // GH#738: If the hub is still in the bootstrap "pending" state, this
    // self-registration completes bootstrap. The flag file is staged
    // alongside allowed_signers in the same atomic commit below.
    let bootstrap_completed_now =
        if let Some(state) = super::bootstrap::read_bootstrap_state(cache_dir) {
            if state.status == "pending" {
                super::bootstrap::complete_bootstrap(cache_dir)?;
                true
            } else {
                false
            }
        } else {
            false
        };

    // Commit unsigned; best-effort. If the commit fails (e.g. nothing
    // staged because of a race), the on-disk file is still correct for
    // local verification, and the next push will pick up any residue.
    if let Err(e) = commit_allowed_signers_unsigned(cache_dir, &principal) {
        tracing::warn!(
            "registered '{principal}' in allowed_signers on disk but commit failed: {e} \
             (run `crosslink sync` to recover)"
        );
    } else if bootstrap_completed_now {
        tracing::info!("bootstrap completed: self-registered '{principal}' as trusted signer");
    }

    Ok(true)
}

/// Compute the conventional public-key path for an SSH private key
/// (`<path>` → `<path>.pub`), preserving any unusual filename shape.
fn with_pub_extension(private_key_path: &Path) -> PathBuf {
    let mut s = private_key_path.as_os_str().to_owned();
    s.push(".pub");
    PathBuf::from(s)
}

/// Stage `trust/allowed_signers` (plus `meta/bootstrap.json` when present,
/// to fold a bootstrap state-flip into the same commit; see GH#738) and
/// commit it without signing.
///
/// Used only by [`register_active_key_as_trusted`]. Unsigned commit is
/// required because the just-added key isn't yet visible in any earlier
/// commit's `allowed_signers` view — signing this commit would create
/// the very verify-commit failure the parent function is meant to fix.
fn commit_allowed_signers_unsigned(cache_dir: &Path, principal: &str) -> Result<()> {
    let run = |args: &[&str]| -> Result<()> {
        let output = Command::new("git")
            .current_dir(cache_dir)
            .args(args)
            .output()
            .with_context(|| format!("failed to spawn git {args:?}"))?;
        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            // `nothing to commit` is benign: the file content matched a
            // previous staged state. Treat it as success.
            if !stderr.contains("nothing to commit") {
                bail!("git {args:?} failed: {}", stderr.trim());
            }
        }
        Ok(())
    };

    run(&["add", "trust/allowed_signers"])?;
    // GH#738: when bootstrap was just completed (file written by the
    // caller before this commit), fold the state-flip into the same
    // atomic commit. Best-effort — if the file is absent or unchanged,
    // `git add` is a no-op and the commit still succeeds.
    if cache_dir.join("meta").join("bootstrap.json").exists() {
        let _ = run(&["add", "meta/bootstrap.json"]);
    }
    run(&[
        "-c",
        "commit.gpgsign=false",
        "commit",
        "-m",
        &format!("trust: register signing key for '{principal}'"),
    ])?;
    Ok(())
}