use crate::home;
use anyhow::Result;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub enum Repair {
Chmod { path: PathBuf, to: u32 },
}
impl Repair {
pub fn manual(&self) -> String {
match self {
Repair::Chmod { path, to } => format!("chmod {to:o} {}", shell_quote(path)),
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Finding {
pub id: &'static str,
pub ok: bool,
pub check: String,
pub applicable: bool,
pub manual: Option<String>,
pub repair: Option<Repair>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Applied {
pub path: PathBuf,
pub was: String,
pub now: String,
pub rechecked_ok: bool,
}
impl std::fmt::Display for Applied {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}, was {}", self.path.display(), self.now, self.was)
}
}
const OWNER_ONLY: u32 = 0o077;
const MODES: bool = cfg!(unix);
pub fn privacy_findings(stores: &[(String, PathBuf)]) -> Vec<Finding> {
let mut out = Vec::new();
let allow_private = std::env::var_os(crate::add::ALLOW_PRIVATE_ENV).is_some();
out.push(Finding {
id: "private addresses",
ok: !allow_private,
check: if allow_private {
format!(
"{} is set, so private addresses are allowed",
crate::add::ALLOW_PRIVATE_ENV
)
} else {
"on".to_string()
},
manual: allow_private.then(|| {
format!(
"unset {} and restart `semlith start`",
crate::add::ALLOW_PRIVATE_ENV
)
}),
repair: None,
applicable: true,
});
let cache = crate::model_cache_dir().unwrap_or_default();
let cache_ok = crate::embed::check_cache_dir(&cache);
out.push(Finding {
id: "model cache",
ok: cache_ok.is_ok(),
check: match &cache_ok {
Ok(()) => format!("{} is yours alone", cache.display()),
Err(e) => e.to_string(),
},
manual: match (&cache_ok, owned_by_us(&cache)) {
(Err(_), true) => Some(
Repair::Chmod {
path: cache.clone(),
to: 0o700,
}
.manual(),
),
_ => None,
},
repair: match (&cache_ok, owned_by_us(&cache)) {
(Err(_), true) => Some(Repair::Chmod {
path: cache.clone(),
to: 0o700,
}),
_ => None,
},
applicable: true,
});
let home_dir = home::home_or_error();
let mut loose: Vec<(String, PathBuf, u32)> = Vec::new();
if let Ok(dir) = &home_dir
&& let Some(mode) = home::loose_mode(dir)
{
loose.push((dir.display().to_string(), dir.clone(), mode));
}
for (name, dir) in stores {
if let Some(mode) = home::loose_mode(dir) {
loose.push((name.clone(), dir.clone(), mode));
}
}
out.push(Finding {
id: "directory modes",
ok: home_dir.is_ok() && loose.is_empty(),
check: match (&home_dir, loose.is_empty()) {
(Err(e), _) => e.to_string(),
(Ok(dir), true) if !MODES => {
format!("{} — this platform has no file mode to read", dir.display())
}
(Ok(dir), true) => format!("{} and every open store are 0700", dir.display()),
(Ok(_), false) => loose
.iter()
.map(|(name, _, mode)| format!("{name} is {mode:o}"))
.collect::<Vec<_>>()
.join(", "),
},
manual: (!loose.is_empty()).then(|| {
loose
.iter()
.map(|(_, dir, _)| {
Repair::Chmod {
path: dir.clone(),
to: 0o700,
}
.manual()
})
.collect::<Vec<_>>()
.join(" && ")
}),
repair: loose.first().map(|(_, dir, _)| Repair::Chmod {
path: dir.clone(),
to: 0o700,
}),
applicable: MODES,
});
let key_path = home::agent_key_path().unwrap_or_default();
let key_mode = mode_of(&key_path);
let key_ok = key_mode.is_none_or(|mode| mode & OWNER_ONLY == 0);
out.push(Finding {
id: "agent key",
ok: key_ok,
check: match key_mode {
Some(mode) if mode & OWNER_ONLY == 0 => {
format!("{} is {mode:o}, owner only", key_path.display())
}
Some(mode) => format!(
"{} is {mode:o}, readable by more than you",
key_path.display()
),
None if !MODES => format!(
"{} — this platform has no file mode to read",
key_path.display()
),
None => format!("{} has no mode to read", key_path.display()),
},
manual: (!key_ok).then(|| {
Repair::Chmod {
path: key_path.clone(),
to: 0o600,
}
.manual()
}),
repair: (!key_ok).then(|| Repair::Chmod {
path: key_path.clone(),
to: 0o600,
}),
applicable: MODES,
});
out
}
pub fn apply(repair: &Repair, stores: &[(String, PathBuf)]) -> Result<Applied> {
let Repair::Chmod { path, to } = repair;
let was = mode_of(path).map_or_else(|| "no mode".to_string(), |mode| format!("{mode:o}"));
if let Some(current) = mode_of(path)
&& to & !current != 0
{
anyhow::bail!(
"{} is {current:o} and the repair would set {to:o}, which grants access rather than removing it. Refused.",
path.display()
);
}
if !owned_by_us(path) {
anyhow::bail!(
"{} belongs to another account. semlith repairs only what it owns.",
path.display()
);
}
set_mode(path, *to)?;
crate::daemon::changes::bump(crate::daemon::changes::Domain::Privacy);
let now = mode_of(path).map_or_else(|| "no mode".to_string(), |mode| format!("{mode:o}"));
let rechecked_ok = privacy_findings(stores)
.iter()
.find(|finding| finding.repair.as_ref().is_some_and(|next| next == repair))
.is_none_or(|finding| finding.ok);
Ok(Applied {
path: path.clone(),
was,
now,
rechecked_ok,
})
}
pub fn apply_all(stores: &[(String, PathBuf)]) -> Vec<Result<Applied>> {
let mut out = Vec::new();
while let Some(repair) = privacy_findings(stores)
.into_iter()
.filter(|finding| !finding.ok)
.find_map(|finding| finding.repair)
{
let applied = apply(&repair, stores);
let failed = applied.is_err();
out.push(applied);
if failed {
break;
}
if out.len() > 64 {
break;
}
}
out
}
fn mode_of(path: &Path) -> Option<u32> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|m| m.permissions().mode() & 0o777)
.ok()
}
#[cfg(not(unix))]
{
let _ = path;
None
}
}
fn set_mode(path: &Path, mode: u32) -> Result<()> {
#[cfg(unix)]
{
use anyhow::Context;
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
.with_context(|| format!("setting {} to {mode:o}", path.display()))?;
Ok(())
}
#[cfg(not(unix))]
{
let _ = (path, mode);
anyhow::bail!("this platform has no file mode to set")
}
}
fn owned_by_us(path: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let me = unsafe { libc::getuid() };
std::fs::metadata(path).is_ok_and(|m| m.uid() == me)
}
#[cfg(not(unix))]
{
let _ = path;
true
}
}
fn shell_quote(path: &Path) -> String {
let text = path.display().to_string();
if text
.chars()
.all(|c| c.is_ascii_alphanumeric() || "._-/~".contains(c))
{
return text;
}
format!("'{}'", text.replace('\'', r"'\''"))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn a_repair_that_would_widen_access_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("key");
std::fs::write(&path, "sml_test").unwrap();
set_mode(&path, 0o600).unwrap();
let widen = Repair::Chmod {
path: path.clone(),
to: 0o644,
};
let refused = apply(&widen, &[]).unwrap_err().to_string();
assert!(
refused.contains("grants access"),
"a widening repair was not refused: {refused}"
);
assert_eq!(mode_of(&path), Some(0o600), "the file was changed anyway");
}
#[cfg(unix)]
#[test]
fn a_repair_is_idempotent_and_reports_both_states() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("key");
std::fs::write(&path, "sml_test").unwrap();
set_mode(&path, 0o644).unwrap();
let narrow = Repair::Chmod {
path: path.clone(),
to: 0o600,
};
let first = apply(&narrow, &[]).unwrap();
assert_eq!(first.was, "644");
assert_eq!(first.now, "600");
assert_eq!(mode_of(&path), Some(0o600));
let second = apply(&narrow, &[]).unwrap();
assert_eq!(second.was, "600", "the second run saw a different state");
assert_eq!(second.now, "600", "the second run changed something");
}
#[test]
fn every_repair_has_the_manual_command_that_matches_it() {
for finding in privacy_findings(&[]) {
match (&finding.repair, &finding.manual) {
(Some(repair), Some(manual)) => assert!(
manual.contains(&repair.manual()),
"{}'s manual step does not contain its repair: {manual}",
finding.id
),
(Some(_), None) => {
panic!("{} offers a repair with no manual step", finding.id)
}
_ => {}
}
}
}
#[test]
fn private_addresses_has_no_repair() {
let finding = privacy_findings(&[])
.into_iter()
.find(|f| f.id == "private addresses")
.expect("the rule is reported");
assert!(
finding.repair.is_none(),
"a rule a daemon cannot repair was given a button"
);
}
#[test]
fn the_mode_rules_are_not_applicable_where_there_is_no_mode() {
for finding in privacy_findings(&[]) {
let is_a_mode_rule = matches!(finding.id, "directory modes" | "agent key");
assert_eq!(
finding.applicable,
!is_a_mode_rule || MODES,
"{} reports applicable = {} on a platform where MODES is {MODES}",
finding.id,
finding.applicable
);
if !finding.applicable {
assert!(
finding.repair.is_none() && finding.manual.is_none(),
"{} offers a repair for something it did not measure",
finding.id
);
assert!(
finding.check.contains("no file mode"),
"{} does not say why it was not measured: {}",
finding.id,
finding.check
);
}
}
}
#[test]
fn a_manual_command_quotes_a_path_that_needs_it() {
let repair = Repair::Chmod {
path: PathBuf::from("/tmp/two words/.semlith"),
to: 0o700,
};
assert_eq!(repair.manual(), "chmod 700 '/tmp/two words/.semlith'");
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ClientReport {
pub name: String,
pub command: Option<String>,
pub present: bool,
pub registered: bool,
pub scope: Option<&'static str>,
pub files: Vec<FileReport>,
pub repair: Option<String>,
pub note: Option<String>,
pub fault: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct FileReport {
pub path: PathBuf,
pub exists: bool,
pub parses: bool,
pub names_semlith: bool,
}
pub fn clients_report() -> Vec<ClientReport> {
let project_scoped = claude_project_entries();
crate::clients::clients()
.iter()
.map(|client| {
let command = client
.register_command()
.and_then(|c| c.split_whitespace().next().map(str::to_string));
let present = command.as_deref().is_some_and(on_path);
let files: Vec<FileReport> = client
.config_files()
.filter_map(|stanza| crate::clientfile::resolve(stanza).map(|p| (stanza, p)))
.map(|(stanza, path)| {
let text = std::fs::read_to_string(&path).ok();
FileReport {
exists: text.is_some(),
parses: match (&text, stanza.format.as_str()) {
(None, _) => true,
(Some(text), "json") => {
serde_json::from_str::<serde_json::Value>(text).is_ok()
}
_ => true,
},
names_semlith: text.as_deref().is_some_and(|t| t.contains("semlith")),
path,
}
})
.collect();
let in_a_file = files.iter().any(|f| f.names_semlith);
let only_project = client.name == "Claude Code" && !project_scoped.is_empty();
let registered = in_a_file || (client.name == "Claude Code" && claude_user_entry());
let note = if crate::clients::UNREGISTERABLE.contains(&client.name.as_str()) {
Some(client.note.clone())
} else {
None
};
let repair = if note.is_some() {
None
} else if only_project && !registered {
Some(format!(
"semlith setup # removes {} project-scope registration(s) and registers at user scope",
project_scoped.len()
))
} else if !registered && client.needs_a_file_written() {
Some("semlith setup --register-all".to_string())
} else if !registered && present {
Some("semlith setup".to_string())
} else {
None
};
let in_use = present
|| files
.iter()
.any(|f| f.exists || f.path.parent().is_some_and(Path::exists));
let fault = note.is_none() && in_use && !registered;
ClientReport {
name: client.name.clone(),
command,
present,
registered,
fault,
scope: match (registered, only_project) {
(true, _) => Some("user"),
(false, true) => Some("project"),
(false, false) => None,
},
files,
repair,
note,
}
})
.collect()
}
fn claude_project_entries() -> Vec<String> {
let Some(config) = claude_config() else {
return Vec::new();
};
config
.get("projects")
.and_then(|p| p.as_object())
.map(|projects| {
projects
.iter()
.filter(|(_, value)| {
value
.get("mcpServers")
.and_then(|s| s.get("semlith"))
.is_some()
})
.map(|(path, _)| path.clone())
.collect()
})
.unwrap_or_default()
}
fn claude_user_entry() -> bool {
claude_config().is_some_and(|config| {
config
.get("mcpServers")
.and_then(|s| s.get("semlith"))
.is_some()
})
}
fn claude_config() -> Option<serde_json::Value> {
let path = home::user_home().ok()?.join(".claude.json");
serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
}
fn on_path(program: &str) -> bool {
if program.contains('/') || program.contains('\\') {
return Path::new(program).exists();
}
let Some(paths) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&paths).any(|dir| {
let candidate = dir.join(program);
candidate.is_file()
|| std::env::var_os("PATHEXT")
.map(|exts| {
std::env::split_paths(&exts)
.any(|ext| dir.join(format!("{program}{}", ext.display())).is_file())
})
.unwrap_or(false)
})
}