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, SettingsFormat};
use serde_json::{Map, Value};
use crate::home::Home;
use crate::install::HostConfig;
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 set(root.get("extraKnownMarketplaces")) {
found.push(
"`extraKnownMarketplaces` adds plugin marketplaces, whose plugins can carry hooks"
.to_owned(),
);
}
for key in ["enableAllProjectMcpServers", "enabledMcpjsonServers"] {
if set(root.get(key)) {
found.push(format!(
"`{key}` starts MCP servers the project's `.mcp.json` names, which runs their \
commands (OpenMoat still holds back their tool calls)"
));
}
}
if root.get("sandbox").is_some_and(|v| !only_enables(v)) {
found.push("`sandbox` changes Claude Code's sandbox settings".to_owned());
}
found
}
#[derive(Debug, Clone, Copy)]
pub struct Reading<'a> {
pub format: SettingsFormat,
pub base: &'a Path,
pub hook_file: &'a Path,
}
impl Reading<'_> {
fn findings(&self, root: &Map<String, Value>) -> Vec<String> {
match self.format {
SettingsFormat::ClaudeCode => findings(root),
SettingsFormat::Gemini => gemini::findings(root),
SettingsFormat::Pi => pi::findings(root, self.base, self.hook_file),
SettingsFormat::Codex => codex::findings(root),
SettingsFormat::Cursor => cursor_findings(root),
}
}
fn switched_off(&self, root: &Map<String, Value>) -> Vec<String> {
match self.format {
SettingsFormat::ClaudeCode => findings(root)
.into_iter()
.filter(|found| found.starts_with("`disableAllHooks`"))
.collect(),
SettingsFormat::Gemini => gemini::switched_off(root),
SettingsFormat::Pi => pi::switched_off(root, self.base, self.hook_file),
SettingsFormat::Codex => codex::switched_off(root),
SettingsFormat::Cursor => Vec::new(),
}
}
}
fn cursor_findings(root: &Map<String, Value>) -> Vec<String> {
if set(root.get("hooks")) {
vec!["`hooks` adds hooks that run commands".to_owned()]
} else {
Vec::new()
}
}
#[must_use]
pub fn switched_off(host: Host, config_dir: &Path, hook_file: &Path) -> Option<String> {
let spec = host.spec().project_settings.as_ref()?;
let path = config_dir.join(spec.user_file);
let reading = Reading {
format: spec.format,
base: config_dir,
hook_file,
};
let found = match read_bytes(&path) {
Ok(None) => return None,
Ok(Some(bytes)) => match parse(&path, &bytes) {
Ok(root) => reading.switched_off(&root),
Err(e) => vec![format!("cannot be checked: {e:#}")],
},
Err(e) => vec![format!("cannot be checked: {e:#}")],
};
(!found.is_empty()).then(|| format!("{}: {}", path.display(), found.join("; ")))
}
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,
}
}
fn read_bytes(path: &Path) -> Result<Option<Vec<u8>>> {
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 bytes = Vec::new();
file.take(MAX_BYTES + 1)
.read_to_end(&mut bytes)
.with_context(|| format!("reading {}", path.display()))?;
if bytes.len() as u64 > MAX_BYTES {
bail!("{} is larger than {MAX_BYTES} bytes", path.display());
}
Ok(Some(bytes))
}
fn parse(path: &Path, bytes: &[u8]) -> Result<Map<String, Value>> {
if path.extension().is_some_and(|e| e == "toml") {
let text = std::str::from_utf8(bytes)
.with_context(|| format!("{} is not UTF-8", path.display()))?;
let doc: toml_edit::DocumentMut = text
.parse()
.with_context(|| format!("{} is not valid TOML", path.display()))?;
return match crate::mcp::servers::toml_to_json(doc.as_item()) {
Value::Object(root) => Ok(root),
_ => bail!("{} is not a TOML table", path.display()),
};
}
match serde_json::from_slice(bytes) {
Ok(Value::Object(root)) => Ok(root),
Ok(_) => bail!("{} is not a JSON object", path.display()),
Err(e) => Err(e).with_context(|| format!("{} is not valid JSON", path.display())),
}
}
fn judge(path: &Path, bytes: Option<&[u8]>) -> Option<String> {
judge_with(path, bytes, findings)
}
fn judge_with(
path: &Path,
bytes: Option<&[u8]>,
found: impl Fn(&Map<String, Value>) -> Vec<String>,
) -> Option<String> {
match bytes.map(|bytes| parse(path, bytes)) {
None => None,
Some(Ok(root)) => {
let found = found(&root);
(!found.is_empty()).then(|| found.join("; "))
}
Some(Err(e)) => Some(format!("cannot be checked: {e:#}")),
}
}
#[cfg(test)]
fn check(path: &Path) -> Option<String> {
check_as(path, findings)
}
fn check_as(path: &Path, found: impl Fn(&Map<String, Value>) -> Vec<String>) -> Option<String> {
match read_bytes(path) {
Ok(bytes) => judge_with(path, bytes.as_deref(), found),
Err(e) => Some(format!("cannot be checked: {e:#}")),
}
}
#[must_use]
pub fn check_unchanged(path: &Path) -> Option<String> {
check_between(path, || {})
}
fn check_between(path: &Path, between: impl FnOnce()) -> Option<String> {
let first = match read_bytes(path) {
Ok(bytes) => bytes,
Err(e) => return Some(format!("cannot be checked: {e:#}")),
};
if let Some(found) = judge(path, first.as_deref()) {
return Some(found);
}
between();
match read_bytes(path) {
Ok(second) if second == first => None,
Ok(_) => Some("changed while OpenMoat was checking it".to_owned()),
Err(e) => Some(format!("cannot be checked: {e:#}")),
}
}
#[must_use]
pub fn candidates(
cwd: &Path,
user_home: &Path,
spec: &ProjectSettingsSpec,
name: &str,
) -> Vec<PathBuf> {
project_dirs(cwd, user_home)
.into_iter()
.map(|dir| dir.join(spec.dir).join(name))
.collect()
}
fn project_dirs(cwd: &Path, user_home: &Path) -> 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.retain(|dir| crate::project::trusted(dir, user_home));
dirs
}
fn hook_files(dir: &Path, extensions: &[&str]) -> Vec<PathBuf> {
let Ok(entries) = fs::read_dir(dir) else {
return Vec::new();
};
let known = |path: &Path| {
path.extension()
.is_some_and(|e| extensions.iter().any(|x| e == *x))
};
let mut files: Vec<PathBuf> = entries
.filter_map(|entry| Some(entry.ok()?.path()))
.filter(|path| {
(known(path) && path.is_file())
|| (path.is_dir()
&& extensions
.iter()
.any(|x| path.join(format!("index.{x}")).is_file()))
})
.collect();
files.sort();
files
}
#[must_use]
pub fn warnings(cwd: &Path, user_home: &Path, host: Host, hook_file: &Path) -> Vec<String> {
let Some(spec) = host.spec().project_settings.as_ref() else {
return Vec::new();
};
let row = |path: &Path, found: &str| {
format!(
"{}: {found}. A {} session started here loads it; review the file \
and remove what you did not add",
path.display(),
host.display_name()
)
};
let mut rows: Vec<String> = Vec::new();
for name in spec.files() {
for path in candidates(cwd, user_home, spec, name) {
let base = path.parent().unwrap_or(&path).to_path_buf();
let reading = Reading {
format: spec.format,
base: &base,
hook_file,
};
if let Some(found) = check_as(&path, |root| reading.findings(root)) {
rows.push(row(&path, &found));
}
}
}
if let Some(dir) = &spec.hook_dir {
let what = match spec.format {
SettingsFormat::Pi => "is an extension pi loads with the agent's rights",
SettingsFormat::ClaudeCode
| SettingsFormat::Gemini
| SettingsFormat::Codex
| SettingsFormat::Cursor => "adds hooks that run commands",
};
for path in project_dirs(cwd, user_home)
.into_iter()
.flat_map(|root| hook_files(&root.join(dir.path), dir.extensions))
{
rows.push(row(&path, what));
}
}
rows
}
pub struct Warning {
pub host: Host,
pub text: String,
}
#[must_use]
pub fn warnings_here(home: &Home) -> 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, HostConfig::for_host(home, host).ok()?)))
.flat_map(|(host, config)| {
warnings(&cwd, &user_home, host, &config.settings_path)
.into_iter()
.map(move |text| Warning { host, text })
})
.collect()
}
mod codex;
mod gemini;
mod pi;
#[cfg(test)]
mod tests;