use std::fs;
use std::io::{self, Read as _};
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail};
use openmoat_hosts::{Host, ProjectSettingsSpec};
use serde_json::{Map, Value};
pub const RULE: &str = "project-settings";
const MAX_BYTES: u64 = 1024 * 1024;
const COMMAND_KEYS: [(&str, &str); 7] = [
("apiKeyHelper", "the command that makes the API key"),
(
"awsAuthRefresh",
"the command that refreshes AWS credentials",
),
(
"awsCredentialExport",
"the command that exports AWS credentials",
),
(
"otelHeadersHelper",
"the command that makes telemetry headers",
),
("statusLine", "a status line command"),
("subagentStatusLine", "a subagent status line command"),
("fileSuggestion", "a file suggestion command"),
];
#[must_use]
pub fn findings(root: &Map<String, Value>) -> Vec<String> {
let mut found = Vec::new();
if root
.get("disableAllHooks")
.is_some_and(|v| v != &Value::Bool(false))
{
found.push("`disableAllHooks` turns off every hook, OpenMoat's included".to_owned());
}
if set(root.get("hooks")) {
found.push("`hooks` adds hooks that run commands".to_owned());
}
if set(root.get("env")) {
found.push(
"`env` sets environment variables for the session (an API endpoint, a key, a proxy)"
.to_owned(),
);
}
for (key, what) in COMMAND_KEYS {
if set(root.get(key)) {
found.push(format!("`{key}` sets {what}"));
}
}
if root
.get("enabledPlugins")
.and_then(Value::as_object)
.is_some_and(|plugins| plugins.values().any(|v| v != &Value::Bool(false)))
{
found.push("`enabledPlugins` turns on plugins, which can carry hooks".to_owned());
}
if root.get("sandbox").is_some_and(|v| !only_enables(v)) {
found.push("`sandbox` changes Claude Code's sandbox settings".to_owned());
}
found
}
fn set(value: Option<&Value>) -> bool {
match value {
None | Some(Value::Null | Value::Bool(false)) => false,
Some(Value::String(s)) => !s.is_empty(),
Some(Value::Object(o)) => !o.is_empty(),
Some(Value::Array(a)) => !a.is_empty(),
Some(_) => true,
}
}
fn only_enables(sandbox: &Value) -> bool {
match sandbox {
Value::Null => true,
Value::Object(o) => o
.iter()
.all(|(k, v)| k == "enabled" && v == &Value::Bool(true)),
_ => false,
}
}
pub fn read(path: &Path) -> Result<Option<Map<String, Value>>> {
let file = match fs::File::open(path) {
Ok(file) => file,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
};
let mut text = String::new();
file.take(MAX_BYTES + 1)
.read_to_string(&mut text)
.with_context(|| format!("reading {}", path.display()))?;
if text.len() as u64 > MAX_BYTES {
bail!("{} is larger than {MAX_BYTES} bytes", path.display());
}
match serde_json::from_str(&text) {
Ok(Value::Object(root)) => Ok(Some(root)),
Ok(_) => bail!("{} is not a JSON object", path.display()),
Err(e) => Err(e).with_context(|| format!("{} is not valid JSON", path.display())),
}
}
#[must_use]
pub fn check(path: &Path) -> Option<String> {
match read(path) {
Ok(None) => None,
Ok(Some(root)) => {
let found = findings(&root);
(!found.is_empty()).then(|| found.join("; "))
}
Err(e) => Some(format!("cannot be checked: {e:#}")),
}
}
#[must_use]
pub fn candidates(
cwd: &Path,
user_home: &Path,
spec: &ProjectSettingsSpec,
name: &str,
) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = vec![cwd.to_path_buf()];
if let Some(root) = crate::project::root_of(cwd, user_home)
&& !dirs.contains(&root)
{
dirs.push(root);
}
dirs.into_iter()
.filter(|dir| crate::project::trusted(dir, user_home))
.map(|dir| dir.join(spec.dir).join(name))
.collect()
}
#[must_use]
pub fn warnings(cwd: &Path, user_home: &Path, spec: &ProjectSettingsSpec) -> Vec<String> {
spec.files()
.iter()
.flat_map(|name| candidates(cwd, user_home, spec, name))
.filter_map(|path| {
check(&path).map(|found| {
format!(
"{}: {found}. A Claude Code session started here loads it; review the file \
and remove what you did not add",
path.display()
)
})
})
.collect()
}
pub struct Warning {
pub host: Host,
pub text: String,
}
#[must_use]
pub fn warnings_here() -> Vec<Warning> {
let (Ok(cwd), Ok(user_home)) = (std::env::current_dir(), crate::home::user_home()) else {
return Vec::new();
};
Host::ALL
.into_iter()
.filter_map(|host| Some((host, host.spec().project_settings.as_ref()?)))
.flat_map(|(host, spec)| {
warnings(&cwd, &user_home, spec)
.into_iter()
.map(move |text| Warning { host, text })
})
.collect()
}
#[cfg(test)]
mod tests;