use crate::{
config::LintConfig,
diagnostics::Diagnostic,
rules::{Validator, ValidatorMetadata},
};
use rust_i18n::t;
use std::path::Path;
const RULE_IDS: &[&str] = &["GM-AG-001"];
const VALID_AUTH_TYPES: &[&str] = &["google-credentials", "oauth"];
pub struct GeminiAgentValidator;
impl Validator for GeminiAgentValidator {
fn metadata(&self) -> ValidatorMetadata {
ValidatorMetadata {
name: self.name(),
rule_ids: RULE_IDS,
}
}
fn validate(&self, path: &Path, content: &str, config: &LintConfig) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
if !config.is_rule_enabled("GM-AG-001") {
return diagnostics;
}
let Some(frontmatter_raw) = extract_frontmatter(content) else {
return diagnostics;
};
let parsed: serde_yaml::Value = match serde_yaml::from_str(frontmatter_raw) {
Ok(v) => v,
Err(_) => return diagnostics, };
let Some(mcp_servers) = parsed
.get("mcp_servers")
.and_then(serde_yaml::Value::as_mapping)
else {
return diagnostics;
};
for (server_key, server_value) in mcp_servers {
let server_name = server_key.as_str().unwrap_or("<unnamed>").to_string();
let Some(server_obj) = server_value.as_mapping() else {
continue;
};
let Some(auth) = server_obj.get("auth") else {
continue;
};
validate_auth_block(path, content, &server_name, auth, &mut diagnostics);
}
diagnostics
}
}
fn validate_auth_block(
path: &Path,
content: &str,
server: &str,
auth: &serde_yaml::Value,
diagnostics: &mut Vec<Diagnostic>,
) {
let line = find_auth_line(content, server).unwrap_or(1);
let Some(auth_obj) = auth.as_mapping() else {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
line,
0,
"GM-AG-001",
t!("rules.gm_ag_001.not_object", server = server),
)
.with_suggestion(t!("rules.gm_ag_001.suggestion")),
);
return;
};
let Some(type_entry) = auth_obj.get("type") else {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
line,
0,
"GM-AG-001",
t!("rules.gm_ag_001.missing_type", server = server),
)
.with_suggestion(t!("rules.gm_ag_001.suggestion")),
);
return;
};
let Some(type_str) = type_entry.as_str().map(str::to_string) else {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
line,
0,
"GM-AG-001",
t!(
"rules.gm_ag_001.not_string",
server = server,
field = "type"
),
)
.with_suggestion(t!("rules.gm_ag_001.suggestion")),
);
return;
};
if !VALID_AUTH_TYPES.contains(&type_str.as_str()) {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
line,
0,
"GM-AG-001",
t!(
"rules.gm_ag_001.invalid_type",
server = server,
value = type_str.as_str(),
valid = VALID_AUTH_TYPES.join(", ")
),
)
.with_suggestion(t!("rules.gm_ag_001.suggestion")),
);
return;
}
match type_str.as_str() {
"google-credentials" => {
for (k, _) in auth_obj {
let Some(key_str) = k.as_str() else { continue };
if !matches!(key_str, "type" | "scopes") {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
line,
0,
"GM-AG-001",
t!(
"rules.gm_ag_001.unknown_field_google",
server = server,
field = key_str
),
)
.with_suggestion(t!("rules.gm_ag_001.suggestion")),
);
}
}
if let Some(scopes) = auth_obj.get("scopes") {
validate_scopes(path, line, server, scopes, diagnostics);
}
}
"oauth" => {
let oauth_keys = [
"type",
"client_id",
"client_secret",
"scopes",
"authorization_url",
"token_url",
];
for (k, _) in auth_obj {
let Some(key_str) = k.as_str() else { continue };
if !oauth_keys.contains(&key_str) {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
line,
0,
"GM-AG-001",
t!(
"rules.gm_ag_001.unknown_field_oauth",
server = server,
field = key_str
),
)
.with_suggestion(t!("rules.gm_ag_001.suggestion")),
);
}
}
for string_key in ["client_id", "client_secret"] {
if let Some(v) = auth_obj.get(string_key)
&& v.as_str().is_none()
{
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
line,
0,
"GM-AG-001",
t!(
"rules.gm_ag_001.not_string",
server = server,
field = string_key
),
)
.with_suggestion(t!("rules.gm_ag_001.suggestion")),
);
}
}
for url_key in ["authorization_url", "token_url"] {
if let Some(v) = auth_obj.get(url_key) {
match v.as_str() {
None => diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
line,
0,
"GM-AG-001",
t!(
"rules.gm_ag_001.not_string",
server = server,
field = url_key
),
)
.with_suggestion(t!("rules.gm_ag_001.suggestion")),
),
Some(s) if !looks_like_url(s) => diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
line,
0,
"GM-AG-001",
t!(
"rules.gm_ag_001.invalid_url",
server = server,
field = url_key,
value = s
),
)
.with_suggestion(t!("rules.gm_ag_001.suggestion")),
),
_ => {}
}
}
}
if let Some(scopes) = auth_obj.get("scopes") {
validate_scopes(path, line, server, scopes, diagnostics);
}
}
_ => unreachable!("type_str already validated against VALID_AUTH_TYPES"),
}
}
fn validate_scopes(
path: &Path,
line: usize,
server: &str,
scopes: &serde_yaml::Value,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some(seq) = scopes.as_sequence() else {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
line,
0,
"GM-AG-001",
t!("rules.gm_ag_001.scopes_not_array", server = server),
)
.with_suggestion(t!("rules.gm_ag_001.suggestion")),
);
return;
};
for (idx, scope) in seq.iter().enumerate() {
if scope.as_str().is_none() {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
line,
0,
"GM-AG-001",
t!(
"rules.gm_ag_001.scope_not_string",
server = server,
index = idx.to_string().as_str()
),
)
.with_suggestion(t!("rules.gm_ag_001.suggestion")),
);
}
}
}
fn looks_like_url(s: &str) -> bool {
if let Some(rest) = s
.strip_prefix("https://")
.or_else(|| s.strip_prefix("http://"))
{
!rest.is_empty()
&& !rest.starts_with('/')
&& !rest.starts_with('?')
&& !rest.starts_with('#')
} else {
false
}
}
fn extract_frontmatter(content: &str) -> Option<&str> {
let stripped = content.strip_prefix('\u{FEFF}').unwrap_or(content);
let rest = stripped
.strip_prefix("---\n")
.or_else(|| stripped.strip_prefix("---\r\n"))?;
let mut offset = 0usize;
for line in rest.split_inclusive('\n') {
if line.trim_end() == "---" {
return Some(&rest[..offset]);
}
offset += line.len();
}
None
}
fn find_auth_line(content: &str, server: &str) -> Option<usize> {
let mut in_mcp_servers = false;
let mut in_server = false;
let server_prefix = format!("{server}:");
let server_quoted_prefix = format!("\"{server}\":");
for (idx, line) in content.lines().enumerate() {
let trimmed = line.trim_start();
let lead_ws_len = line.len() - trimmed.len();
if trimmed == "mcp_servers:" {
in_mcp_servers = true;
in_server = false;
continue;
}
if in_mcp_servers {
if lead_ws_len == 0 && !trimmed.starts_with('#') && !trimmed.is_empty() {
in_mcp_servers = false;
continue;
}
if trimmed.starts_with(&server_prefix) || trimmed.starts_with(&server_quoted_prefix) {
in_server = true;
continue;
}
if in_server {
if lead_ws_len <= 2 && trimmed.ends_with(':') && !trimmed.starts_with("auth:") {
in_server = false;
continue;
}
if trimmed.starts_with("auth:") {
return Some(idx + 1);
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::LintConfig;
use std::path::PathBuf;
fn validate(content: &str) -> Vec<Diagnostic> {
let validator = GeminiAgentValidator;
validator.validate(
&PathBuf::from(".gemini/agents/test.md"),
content,
&LintConfig::default(),
)
}
const AGENT_HEADER: &str = "---\nkind: local\nname: test-agent\ndescription: Test\n";
const AGENT_FOOTER: &str = "system_prompt: You are a test.\n---\n\nBody.\n";
fn wrap_mcp(mcp: &str) -> String {
format!("{AGENT_HEADER}mcp_servers:\n{mcp}{AGENT_FOOTER}")
}
#[test]
fn test_google_credentials_with_scopes_is_valid() {
let content = wrap_mcp(
" spanner:\n url: https://spanner.googleapis.com/mcp\n type: http\n auth:\n type: google-credentials\n scopes:\n - https://www.googleapis.com/auth/cloud-platform\n",
);
let diagnostics = validate(&content);
assert!(
diagnostics.is_empty(),
"Valid google-credentials auth must not flag, got {:?}",
diagnostics
);
}
#[test]
fn test_oauth_full_shape_is_valid() {
let content = wrap_mcp(
" myserver:\n url: https://example.com/mcp\n type: http\n auth:\n type: oauth\n client_id: abc\n client_secret: secret\n scopes: [read, write]\n authorization_url: https://accounts.example.com/authorize\n token_url: https://accounts.example.com/token\n",
);
let diagnostics = validate(&content);
assert!(
diagnostics.is_empty(),
"Valid oauth auth must not flag, got {:?}",
diagnostics
);
}
#[test]
fn test_google_credentials_no_scopes_is_valid() {
let content = wrap_mcp(
" myserver:\n url: https://example.com/mcp\n auth:\n type: google-credentials\n",
);
let diagnostics = validate(&content);
assert!(diagnostics.is_empty());
}
#[test]
fn test_oauth_minimal_shape_is_valid() {
let content = wrap_mcp(
" myserver:\n url: https://example.com/mcp\n auth:\n type: oauth\n",
);
let diagnostics = validate(&content);
assert!(diagnostics.is_empty());
}
#[test]
fn test_no_auth_block_is_fine() {
let content = wrap_mcp(" myserver:\n url: https://example.com/mcp\n");
let diagnostics = validate(&content);
assert!(diagnostics.is_empty());
}
#[test]
fn test_no_mcp_servers_is_fine() {
let content = format!("{AGENT_HEADER}{AGENT_FOOTER}");
let diagnostics = validate(&content);
assert!(diagnostics.is_empty());
}
#[test]
fn test_missing_frontmatter_is_silent() {
let diagnostics = validate("# Just markdown, no frontmatter\n");
assert!(diagnostics.is_empty());
}
#[test]
fn test_missing_type_flags() {
let content = wrap_mcp(" myserver:\n auth:\n scopes:\n - foo\n");
let diagnostics = validate(&content);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "GM-AG-001")
.collect();
assert_eq!(hits.len(), 1);
assert!(hits[0].message.to_lowercase().contains("missing"));
}
#[test]
fn test_non_string_type_flags_as_type_not_string() {
let content = wrap_mcp(" myserver:\n auth:\n type: 12345\n");
let diagnostics = validate(&content);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "GM-AG-001")
.collect();
assert_eq!(hits.len(), 1);
let msg = hits[0].message.to_lowercase();
assert!(
msg.contains("auth.type") && msg.contains("string"),
"expected 'auth.type must be a string' style message, got: {}",
hits[0].message
);
assert!(!msg.contains("missing"));
}
#[test]
fn test_invalid_type_flags() {
let content = wrap_mcp(" myserver:\n auth:\n type: basic-auth\n");
let diagnostics = validate(&content);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "GM-AG-001")
.collect();
assert_eq!(hits.len(), 1);
assert!(hits[0].message.contains("basic-auth"));
assert!(hits[0].message.contains("google-credentials"));
}
#[test]
fn test_non_object_auth_flags() {
let content = wrap_mcp(" myserver:\n auth: \"google-credentials\"\n");
let diagnostics = validate(&content);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "GM-AG-001")
.collect();
assert_eq!(hits.len(), 1);
}
#[test]
fn test_oauth_field_on_google_credentials_flags() {
let content = wrap_mcp(
" myserver:\n auth:\n type: google-credentials\n client_id: abc\n",
);
let diagnostics = validate(&content);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "GM-AG-001")
.collect();
assert_eq!(hits.len(), 1);
assert!(hits[0].message.contains("client_id"));
}
#[test]
fn test_unknown_field_on_oauth_flags() {
let content =
wrap_mcp(" myserver:\n auth:\n type: oauth\n random_extra: yes\n");
let diagnostics = validate(&content);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "GM-AG-001")
.collect();
assert_eq!(hits.len(), 1);
assert!(hits[0].message.contains("random_extra"));
}
#[test]
fn test_client_id_non_string_flags() {
let content =
wrap_mcp(" myserver:\n auth:\n type: oauth\n client_id: 12345\n");
let diagnostics = validate(&content);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "GM-AG-001")
.collect();
assert_eq!(hits.len(), 1);
assert!(hits[0].message.contains("client_id"));
}
#[test]
fn test_authorization_url_malformed_flags() {
let content = wrap_mcp(
" myserver:\n auth:\n type: oauth\n authorization_url: \"not a url\"\n",
);
let diagnostics = validate(&content);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "GM-AG-001")
.collect();
assert_eq!(hits.len(), 1);
assert!(hits[0].message.contains("authorization_url"));
}
#[test]
fn test_authorization_url_valid_https_accepted() {
let content = wrap_mcp(
" myserver:\n auth:\n type: oauth\n authorization_url: https://example.com/authorize\n",
);
let diagnostics = validate(&content);
assert!(diagnostics.is_empty());
}
#[test]
fn test_scopes_not_array_flags() {
let content = wrap_mcp(
" myserver:\n auth:\n type: google-credentials\n scopes: one-scope\n",
);
let diagnostics = validate(&content);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "GM-AG-001")
.collect();
assert_eq!(hits.len(), 1);
}
#[test]
fn test_scope_non_string_flags() {
let content = wrap_mcp(
" myserver:\n auth:\n type: google-credentials\n scopes:\n - 12345\n",
);
let diagnostics = validate(&content);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "GM-AG-001")
.collect();
assert_eq!(hits.len(), 1);
}
#[test]
fn test_per_server_independent_validation() {
let content = wrap_mcp(
" s1:\n auth:\n type: google-credentials\n s2:\n auth:\n type: basic-auth\n",
);
let diagnostics = validate(&content);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "GM-AG-001")
.collect();
assert_eq!(hits.len(), 1, "only s2 should flag, got {:?}", hits);
assert!(hits[0].message.contains("s2"));
}
#[test]
fn test_can_be_disabled() {
let mut config = LintConfig::default();
config.rules_mut().disabled_rules = vec!["GM-AG-001".to_string()];
let content = wrap_mcp(" myserver:\n auth:\n type: basic-auth\n");
let validator = GeminiAgentValidator;
let diagnostics =
validator.validate(&PathBuf::from(".gemini/agents/test.md"), &content, &config);
assert!(diagnostics.is_empty());
}
#[test]
fn test_looks_like_url() {
assert!(looks_like_url("https://example.com"));
assert!(looks_like_url("http://localhost:8080/path"));
assert!(!looks_like_url("not-a-url"));
assert!(!looks_like_url("ftp://example.com"));
assert!(!looks_like_url("https://"));
assert!(!looks_like_url("https:///empty-host"));
}
#[test]
fn test_extract_frontmatter_basic() {
let content = "---\nkey: value\n---\nBody\n";
assert_eq!(extract_frontmatter(content), Some("key: value\n"));
}
#[test]
fn test_extract_frontmatter_bom() {
let content = "\u{FEFF}---\nkey: value\n---\nBody\n";
assert_eq!(extract_frontmatter(content), Some("key: value\n"));
}
#[test]
fn test_extract_frontmatter_absent() {
assert_eq!(extract_frontmatter("# No frontmatter\n"), None);
}
#[test]
fn test_extract_frontmatter_unterminated() {
assert_eq!(extract_frontmatter("---\nkey: value\nno closing\n"), None);
}
}