use crate::gitcmd;
use std::path::Path;
#[derive(Debug, PartialEq, Eq)]
pub enum Level {
Ok,
Warn,
Info,
}
impl Level {
fn glyph(&self) -> &'static str {
match self {
Level::Ok => "✓",
Level::Warn => "⚠",
Level::Info => "ℹ",
}
}
}
pub struct Finding {
pub level: Level,
pub title: String,
pub fix: Option<String>,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum Scope {
Local,
Global,
System,
Unknown,
}
impl Scope {
pub fn from_origin(path: &str) -> Scope {
if path.contains(".git/config") {
Scope::Local
} else if path.contains("/etc/") {
Scope::System
} else if path.contains(".gitconfig") || path.contains("/git/config") {
Scope::Global
} else {
Scope::Unknown
}
}
fn label(&self) -> &'static str {
match self {
Scope::Local => "local",
Scope::Global => "global",
Scope::System => "system",
Scope::Unknown => "?",
}
}
}
fn scoped(root: &Path, key: &str) -> Option<(Scope, String)> {
let out = gitcmd::output(root, &["config", "--show-origin", "--get", key]).ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout);
let line = s.lines().next()?; let (origin, value) = line.split_once('\t')?;
let path = origin.strip_prefix("file:").unwrap_or(origin);
Some((Scope::from_origin(path), value.trim().to_string()))
}
fn is_interactive_signer(program: &str) -> bool {
let p = program.to_lowercase();
p.contains("op-ssh-sign") || p.contains("1password") || p.contains("gpg-agent")
}
pub fn audit(root: &Path) -> Vec<Finding> {
let mut f = Vec::new();
let agent_clone = root.join(".confer").join("identity.json").exists();
f.push(Finding {
level: Level::Info,
title: if agent_clone {
"This is an agent clone (has .confer/identity.json) — local signing config is correct here.".into()
} else {
"This looks like a human-used clone (no .confer/identity.json) — an agent must NOT write signing config here.".into()
},
fix: None,
});
let gpgsign = scoped(root, "commit.gpgsign");
let program = scoped(root, "gpg.ssh.program");
let signingkey = scoped(root, "user.signingkey");
let email = scoped(root, "user.email");
if let Some((scope, prog)) = &program {
if is_interactive_signer(prog) {
f.push(Finding {
level: if agent_clone { Level::Warn } else { Level::Info },
title: format!(
"gpg.ssh.program ({}) is an interactive signer ({prog}) — it prompts/blocks in a headless agent.",
scope.label()
),
fix: agent_clone.then(|| {
"give this clone its own on-disk key: `confer join --role <you> --signing-key <path>` (overrides the program to ssh-keygen, no prompt).".into()
}),
});
}
}
if agent_clone {
let signing_on = gpgsign.as_ref().map(|(_, v)| v == "true").unwrap_or(false);
match &signingkey {
Some((Scope::Local, _)) if signing_on => f.push(Finding {
level: Level::Ok,
title: "Signs with a LOCAL signing key (agent-scoped, not the human's).".into(),
fix: None,
}),
Some((Scope::Local, _)) => f.push(Finding {
level: Level::Warn,
title: "A LOCAL signing key is set but commit.gpgsign is OFF — this agent's messages go out UNSIGNED and peers can't verify them.".into(),
fix: Some("turn signing on: `confer join --role <you> --signing-key <path>` (sets commit.gpgsign=true), or `git config commit.gpgsign true`.".into()),
}),
Some((scope, _)) => f.push(Finding {
level: Level::Warn,
title: format!("user.signingkey is inherited from {} config — this agent would sign with the HUMAN's key.", scope.label()),
fix: Some("adopt an agent key: `confer join --role <you> --signing-key <path>`.".into()),
}),
None => f.push(Finding {
level: Level::Info,
title: "No signing key configured — messages will be unverifiable.".into(),
fix: Some("adopt an agent key: `confer join --role <you> --signing-key <path>`.".into()),
}),
}
if let Some((scope, mail)) = &email {
if *scope != Scope::Local {
f.push(Finding {
level: Level::Warn,
title: format!("user.email ({mail}) comes from {} config — agent commits are attributed to the human, not the role.", scope.label()),
fix: Some("join sets a role identity (<role>@confer.local) locally.".into()),
});
}
}
} else {
for (key, val) in [("commit.gpgsign", &gpgsign), ("user.signingkey", &signingkey), ("user.email", &email)] {
if let Some((Scope::Local, v)) = val {
f.push(Finding {
level: Level::Warn,
title: format!("LOCAL override `{key} = {v}` in a human-used clone — this changes how the HUMAN's commits here behave."),
fix: Some("agents should use a SEPARATE clone, or per-invocation `git -c …` overrides — never local config in a shared clone.".into()),
});
}
}
f.push(Finding {
level: Level::Info,
title: "Agents should not commit here directly — give each agent its own sibling clone with its own key.".into(),
fix: None,
});
}
let (url, vis) = remote_visibility(root);
match vis {
Visibility::Public => f.push(Finding {
level: Level::Warn,
title: format!(
"the hub remote appears PUBLIC (anonymously readable): {} — confer assumes a PRIVATE hub, so this exposes all coordination traffic (every message, role, and the full history) and anyone can clone it.",
url.as_deref().unwrap_or("origin")
),
fix: Some("make the repo private, or confirm this is a deliberate public/read-only mirror.".into()),
}),
Visibility::Private => f.push(Finding {
level: Level::Ok,
title: "The hub remote is not anonymously readable (appears private).".into(),
fix: None,
}),
Visibility::Unknown => f.push(Finding {
level: Level::Info,
title: "Couldn't verify hub visibility (offline, SSH-only, or a self-hosted host) — can't confirm it's private; check manually if unsure.".into(),
fix: None,
}),
Visibility::NotApplicable => {} }
f.push(Finding {
level: Level::Info,
title: "confer only ever writes LOCAL git config; it never modifies your global ~/.gitconfig.".into(),
fix: None,
});
f
}
pub fn fix(root: &Path, ssh_keygen: &str) -> anyhow::Result<Vec<String>> {
let mut applied = Vec::new();
let agent_clone = root.join(".confer").join("identity.json").exists();
if !agent_clone {
return Ok(applied); }
let signingkey = scoped(root, "user.signingkey");
if signingkey.is_none() {
return Ok(applied); }
let signing_off = scoped(root, "commit.gpgsign").map(|(_, v)| v != "true").unwrap_or(true);
let interactive = scoped(root, "gpg.ssh.program").map(|(_, p)| is_interactive_signer(&p)).unwrap_or(false);
if signing_off || interactive {
for (k, v) in [("gpg.format", "ssh"), ("gpg.ssh.program", ssh_keygen), ("commit.gpgsign", "true")] {
gitcmd::check(root, &["config", k, v])?;
}
applied.push(
"enabled commit signing with the on-disk agent key (gpg.format=ssh, ssh-keygen program, commit.gpgsign=true) — this clone's messages were going out UNSIGNED/unverifiable".to_string(),
);
}
Ok(applied)
}
#[derive(Debug, PartialEq, Eq)]
pub enum Visibility {
Public,
Private,
Unknown,
NotApplicable,
}
fn origin_url(root: &Path) -> Option<String> {
let out = gitcmd::output(root, &["config", "--get", "remote.origin.url"]).ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!s.is_empty()).then_some(s)
}
pub fn anon_https(url: &str) -> Option<String> {
let u = url.trim();
if let Some(r) = u.strip_prefix("git@") {
Some(format!("https://{}", r.replacen(':', "/", 1)))
} else if let Some(r) = u.strip_prefix("ssh://git@") {
Some(format!("https://{}", r.replacen(':', "/", 1)))
} else if u.starts_with("https://") || u.starts_with("http://") {
Some(u.to_string())
} else {
None }
}
fn probe_public(anon_url: &str) -> Visibility {
use std::process::{Command, Stdio};
let endpoint = format!("{}/info/refs?service=git-upload-pack", anon_url.trim_end_matches('/'));
let mut cmd = Command::new("curl");
cmd.args([
"-sS", "-o", "/dev/null", "-w", "%{http_code}",
"--max-time", "6", "-L", "--max-redirs", "3",
"-A", "confer-doctor",
&endpoint,
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let Ok(child) = cmd.spawn() else {
return Visibility::Unknown; };
let pid = child.id();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(child.wait_with_output());
});
let out = match rx.recv_timeout(std::time::Duration::from_secs(8)) {
Ok(Ok(o)) => o,
_ => {
let _ = Command::new("kill").arg("-9").arg(pid.to_string()).status();
return Visibility::Unknown;
}
};
match String::from_utf8_lossy(&out.stdout).trim() {
"200" => Visibility::Public,
"000" | "" => Visibility::Unknown, _ => Visibility::Private, }
}
pub fn remote_visibility(root: &Path) -> (Option<String>, Visibility) {
let Some(url) = origin_url(root) else {
return (None, Visibility::NotApplicable);
};
match anon_https(&url) {
Some(anon) => (Some(url), probe_public(&anon)),
None => (Some(url), Visibility::NotApplicable),
}
}
pub fn render(findings: &[Finding]) -> String {
let mut out = String::from("confer doctor — git identity & signing audit\n");
for f in findings {
out.push_str(&format!(" {} {}\n", f.level.glyph(), f.title));
if let Some(fix) = &f.fix {
out.push_str(&format!(" → {fix}\n"));
}
}
let warns = findings.iter().filter(|f| f.level == Level::Warn).count();
out.push_str(&if warns == 0 {
"\nNo scope conflicts found.\n".to_string()
} else {
format!("\n{warns} thing(s) to address above.\n")
});
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scope_from_origin_classifies_paths() {
assert_eq!(Scope::from_origin("/Users/x/proj/.git/config"), Scope::Local);
assert_eq!(Scope::from_origin(".git/config"), Scope::Local); assert_eq!(Scope::from_origin("/Users/x/.gitconfig"), Scope::Global);
assert_eq!(Scope::from_origin("/Users/x/.config/git/config"), Scope::Global);
assert_eq!(Scope::from_origin("/etc/gitconfig"), Scope::System);
}
#[test]
fn anon_https_derivation() {
assert_eq!(anon_https("git@github.com:codeshrew/x.git").unwrap(), "https://github.com/codeshrew/x.git");
assert_eq!(anon_https("https://github.com/codeshrew/x.git").unwrap(), "https://github.com/codeshrew/x.git");
assert_eq!(anon_https("ssh://git@gitlab.com/o/x.git").unwrap(), "https://gitlab.com/o/x.git");
assert!(anon_https("file:///tmp/hub.git").is_none()); assert!(anon_https("/tmp/hub.git").is_none());
}
#[test]
fn interactive_signer_detection() {
assert!(is_interactive_signer("/Applications/1Password.app/Contents/MacOS/op-ssh-sign"));
assert!(!is_interactive_signer("/usr/bin/ssh-keygen"));
}
}