use crate::types::{MCPPrompt, MCPPromptArgument, MCPResource, YaraRuleMetadata, YaraScanResult};
use anyhow::{anyhow, Result};
use serde::{Deserialize, Deserializer};
use std::collections::HashSet;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use tracing::{debug, warn};
pub const SKILL_ROOTS_ENV: &str = "RAMPARTS_SKILL_ROOTS";
#[derive(Debug)]
pub struct ParsedSkill {
pub prompt: MCPPrompt,
pub heuristic_findings: Vec<YaraScanResult>,
}
const MAX_SKILL_FILE_BYTES: u64 = 2 * 1024 * 1024;
const NON_SKILL_FILENAME_STEMS: &[&str] = &[
"readme",
"changelog",
"license",
"licence",
"contributing",
"code_of_conduct",
"security",
"support",
"authors",
"notice",
"history",
"upgrade",
"upgrading",
"migration",
"todo",
];
fn is_non_skill_filename(name: &str) -> bool {
let stem = name.rsplit_once('.').map_or(name, |(s, _)| s);
let normalized = stem.to_ascii_lowercase().replace('-', "_");
NON_SKILL_FILENAME_STEMS
.iter()
.any(|candidate| candidate == &normalized)
}
pub(crate) fn is_agentskills_bundle(path: &Path) -> bool {
path.file_name() == Some(OsStr::new("SKILL.md"))
}
pub(crate) fn bundle_root_of(path: &Path) -> Option<&Path> {
if !is_agentskills_bundle(path) {
return None;
}
let parent = path.parent()?;
if parent.as_os_str().is_empty() {
return None;
}
Some(parent)
}
pub(crate) fn is_under_bundle_sibling_dir(path: &Path, bundle_roots: &HashSet<PathBuf>) -> bool {
let Some(parent) = path.parent() else {
return false;
};
let sibling_dir_name = parent.file_name().and_then(|n| n.to_str());
let is_sibling = matches!(sibling_dir_name, Some("scripts" | "references" | "assets"));
if !is_sibling {
return false;
}
parent
.parent()
.is_some_and(|grandparent| bundle_roots.contains(grandparent))
}
const SCRIPT_EXTS: &[&str] = &[
"py", "sh", "bash", "zsh", "js", "mjs", "cjs", "ts", "rb", "pl", "ps1",
];
fn validate_skill_name(name: &str) -> std::result::Result<(), &'static str> {
if name.is_empty() {
return Err("name is empty");
}
if name.len() > 64 {
return Err("name exceeds 64 characters");
}
let bytes = name.as_bytes();
if bytes[0] == b'-' {
return Err("name starts with a hyphen");
}
if bytes[bytes.len() - 1] == b'-' {
return Err("name ends with a hyphen");
}
let mut last_was_hyphen = false;
for &b in bytes {
let ok = matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'-');
if !ok {
return Err("name contains a character outside [a-z0-9-]");
}
if b == b'-' && last_was_hyphen {
return Err("name contains consecutive hyphens");
}
last_was_hyphen = b == b'-';
}
Ok(())
}
const AGENTSKILLS_ALLOWED_FIELDS: &[&str] = &[
"name",
"description",
"license",
"compatibility",
"metadata",
"allowed-tools",
];
#[derive(Debug, Deserialize, Default)]
struct SkillFrontmatter {
description: Option<String>,
#[serde(rename = "argument-hint")]
argument_hint: Option<String>,
name: Option<String>,
#[serde(
rename = "allowed-tools",
default,
deserialize_with = "deser_string_or_seq"
)]
allowed_tools: Option<String>,
#[allow(dead_code)]
license: Option<String>,
#[allow(dead_code)]
compatibility: Option<String>,
#[allow(dead_code)]
metadata: Option<serde_yaml::Value>,
}
fn deser_string_or_seq<'de, D>(deserializer: D) -> std::result::Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::{self, SeqAccess, Visitor};
use std::fmt;
struct StringOrSeq;
impl<'de> Visitor<'de> for StringOrSeq {
type Value = Option<String>;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a string or a sequence of strings")
}
fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<Self::Value, E> {
Ok(Some(v.to_string()))
}
fn visit_string<E: de::Error>(self, v: String) -> std::result::Result<Self::Value, E> {
Ok(Some(v))
}
fn visit_unit<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
Ok(None)
}
fn visit_none<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
Ok(None)
}
fn visit_some<D2: Deserializer<'de>>(
self,
d: D2,
) -> std::result::Result<Self::Value, D2::Error> {
d.deserialize_any(StringOrSeq)
}
fn visit_seq<A: SeqAccess<'de>>(
self,
mut seq: A,
) -> std::result::Result<Self::Value, A::Error> {
let mut parts: Vec<String> = Vec::new();
while let Some(item) = seq.next_element::<serde_yaml::Value>()? {
let s = match item {
serde_yaml::Value::String(s) => s,
other => serde_yaml::to_string(&other)
.unwrap_or_default()
.trim()
.to_string(),
};
if !s.is_empty() {
parts.push(s);
}
}
if parts.is_empty() {
Ok(None)
} else {
Ok(Some(parts.join(", ")))
}
}
}
deserializer.deserialize_any(StringOrSeq)
}
pub fn parse_skill_file(path: &Path) -> Option<ParsedSkill> {
if let Ok(metadata) = std::fs::metadata(path) {
if metadata.len() > MAX_SKILL_FILE_BYTES {
warn!(
"Skipping skill file {} ({} bytes > {} byte limit)",
path.display(),
metadata.len(),
MAX_SKILL_FILE_BYTES
);
return None;
}
}
let content = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
warn!("Skipping skill file {}: {e}", path.display());
return None;
}
};
parse_skill_content(path, &content)
}
pub fn parse_skill_content(path: &Path, raw: &str) -> Option<ParsedSkill> {
let (parsed_fm, body) = split_and_parse_frontmatter(path, raw);
let (raw_fm, _) = split_frontmatter(raw);
let name_for_yaml = parsed_fm
.name
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let dangerous_yaml = raw_fm
.map(|fm| {
analyze_dangerous_yaml(
name_for_yaml.unwrap_or_else(|| {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unnamed")
}),
path,
fm,
)
})
.unwrap_or_default();
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unnamed");
let name = parsed_fm
.name
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map_or_else(|| stem.to_string(), str::to_string);
assemble_skill(path, body, &parsed_fm, name, dangerous_yaml)
}
fn split_and_parse_frontmatter<'a>(path: &Path, raw: &'a str) -> (SkillFrontmatter, &'a str) {
let (frontmatter, body) = split_frontmatter(raw);
let parsed_fm: SkillFrontmatter = frontmatter
.and_then(|fm| match serde_yaml::from_str(fm) {
Ok(v) => Some(v),
Err(e) => {
debug!(
"Skill {} has unparseable frontmatter (treating as no frontmatter): {e}",
path.display()
);
None
}
})
.unwrap_or_default();
(parsed_fm, body)
}
fn assemble_skill(
path: &Path,
body: &str,
parsed_fm: &SkillFrontmatter,
name: String,
extra_findings: Vec<YaraScanResult>,
) -> Option<ParsedSkill> {
let hint = parsed_fm
.argument_hint
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let arguments = hint.and_then(parse_argument_hint);
let fm_description = parsed_fm
.description
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let body_trimmed = body.trim();
if fm_description.is_none() && body_trimmed.is_empty() && hint.is_none() {
debug!(
"Skipping {} (empty skill: no description, body, or argument hint)",
path.display()
);
return None;
}
let mut parts: Vec<String> = Vec::new();
if let Some(d) = fm_description {
parts.push(d.to_string());
}
if !body_trimmed.is_empty() {
parts.push(body_trimmed.to_string());
}
if arguments.is_none() {
if let Some(h) = hint {
parts.push(format!("Argument hint: {h}"));
}
}
let description = if parts.is_empty() {
None
} else {
Some(parts.join("\n\n"))
};
let prompt = MCPPrompt {
name,
description,
arguments,
raw_json: None,
};
let mut heuristic_findings: Vec<YaraScanResult> = extra_findings;
if let Some(grant) = parsed_fm.allowed_tools.as_deref() {
heuristic_findings.extend(analyze_allowed_tools(&prompt.name, path, grant));
}
heuristic_findings.extend(analyze_vague_trigger(
&prompt.name,
path,
fm_description,
body_trimmed,
));
heuristic_findings.extend(analyze_generic_trigger(&prompt.name, path, fm_description));
heuristic_findings.extend(analyze_sensitive_file_references(
&prompt.name,
path,
body_trimmed,
));
heuristic_findings.extend(analyze_embedded_payloads(&prompt.name, path, body_trimmed));
heuristic_findings.extend(analyze_identity_file_access(
&prompt.name,
path,
body_trimmed,
));
heuristic_findings.extend(analyze_external_references(
&prompt.name,
path,
body_trimmed,
));
heuristic_findings.extend(analyze_brand_impersonation(
&prompt.name,
path,
prompt.description.as_deref(),
));
Some(ParsedSkill {
prompt,
heuristic_findings,
})
}
pub(crate) fn parse_agentskills_bundle(
skill_md_path: &Path,
) -> Option<(ParsedSkill, Vec<MCPResource>)> {
if let Ok(metadata) = std::fs::metadata(skill_md_path) {
if metadata.len() > MAX_SKILL_FILE_BYTES {
warn!(
"Skipping SKILL.md {} ({} bytes > {} byte limit)",
skill_md_path.display(),
metadata.len(),
MAX_SKILL_FILE_BYTES
);
return None;
}
}
let raw = match std::fs::read_to_string(skill_md_path) {
Ok(s) => s,
Err(e) => {
warn!("Skipping SKILL.md {}: {e}", skill_md_path.display());
return None;
}
};
parse_agentskills_bundle_content(skill_md_path, &raw)
}
fn parse_agentskills_bundle_content(
skill_md_path: &Path,
raw: &str,
) -> Option<(ParsedSkill, Vec<MCPResource>)> {
let (parsed_fm, body) = split_and_parse_frontmatter(skill_md_path, raw);
let parent_dir_name = skill_md_path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.map(str::to_string);
let fm_name = parsed_fm
.name
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let mut validation_findings: Vec<YaraScanResult> = Vec::new();
if let (Some(fm), Some(dir)) = (fm_name.as_deref(), parent_dir_name.as_deref()) {
if fm != dir {
validation_findings.push(make_heuristic_finding(
"AgentskillsNameMismatch",
"high",
format!(
"SKILL.md declares `name: {fm}` but its parent directory is `{dir}/`. \
agentskills.io requires the name to match the parent directory; the \
mismatch may indicate a deceptively-named bundle. Choose one canonical \
name and use it consistently."
),
fm,
skill_md_path,
));
}
}
let (resolved_name, from_parent_dir) = match (fm_name.as_deref(), parent_dir_name.as_deref()) {
(Some(fm), _) => (fm.to_string(), false),
(None, Some(dir)) if !dir.is_empty() => (dir.to_string(), true),
_ => ("unnamed".to_string(), false),
};
let parent_dir_usable = parent_dir_name.as_deref().is_some_and(|n| !n.is_empty());
if fm_name.is_none() && !parent_dir_usable {
validation_findings.push(make_heuristic_finding(
"AgentskillsMissingName",
"medium",
"SKILL.md has no `name:` field and the parent directory has no usable \
name. agentskills.io requires both to be present and to match."
.to_string(),
&resolved_name,
skill_md_path,
));
} else if let Err(reason) = validate_skill_name(&resolved_name) {
let where_ = if from_parent_dir {
format!("parent directory `{resolved_name}/`")
} else {
format!("frontmatter `name: {resolved_name}`")
};
validation_findings.push(make_heuristic_finding(
"AgentskillsInvalidName",
"medium",
format!(
"{where_} fails agentskills.io name rules: {reason}. Spec requires \
1–64 chars from [a-z0-9-] with no leading/trailing or consecutive hyphens."
),
&resolved_name,
skill_md_path,
));
}
let unknown_keys = detect_unknown_frontmatter_fields(raw);
if !unknown_keys.is_empty() {
let joined = unknown_keys.join(", ");
validation_findings.push(make_heuristic_finding(
"AgentskillsUnknownFrontmatterField",
"low",
format!(
"SKILL.md frontmatter contains key(s) not defined by agentskills.io: \
{joined}. Spec allows only: name, description, license, compatibility, \
metadata, allowed-tools."
),
&resolved_name,
skill_md_path,
));
}
if let (Some(raw_fm), _) = split_frontmatter(raw) {
validation_findings.extend(analyze_dangerous_yaml(
&resolved_name,
skill_md_path,
raw_fm,
));
}
if let Some(bundle_root) = skill_md_path.parent() {
validation_findings.extend(analyze_json_manifests(&resolved_name, bundle_root));
}
let mut parsed = assemble_skill(
skill_md_path,
body,
&parsed_fm,
resolved_name.clone(),
validation_findings,
)?;
let mut resources = Vec::new();
let mut skipped: Vec<String> = Vec::new();
if let Some(bundle_root) = skill_md_path.parent() {
collect_bundle_siblings(bundle_root, &resolved_name, &mut resources, &mut skipped);
}
parsed.heuristic_findings.extend(analyze_undeclared_egress(
&resolved_name,
skill_md_path,
parsed_fm.allowed_tools.as_deref(),
&resources,
));
if !skipped.is_empty() {
parsed.heuristic_findings.push(make_heuristic_finding(
"ScanCoverageIncomplete",
"medium",
format!(
"Bundle '{resolved_name}' was NOT fully scanned — {} file(s) skipped: {}. \
A clean result over a partially-scanned bundle is not evidence the \
bundle is clean; the skipped files are exactly where a payload \
evading the scan would live.",
skipped.len(),
skipped.join("; ")
),
&resolved_name,
skill_md_path,
));
}
Some((parsed, resources))
}
fn detect_unknown_frontmatter_fields(raw: &str) -> Vec<String> {
let (Some(fm), _) = split_frontmatter(raw) else {
return Vec::new();
};
let parsed: serde_yaml::Value = match serde_yaml::from_str(fm) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let Some(mapping) = parsed.as_mapping() else {
return Vec::new();
};
let allowed: HashSet<&str> = AGENTSKILLS_ALLOWED_FIELDS.iter().copied().collect();
let mut unknown: Vec<String> = mapping
.iter()
.filter_map(|(k, _)| k.as_str().map(str::to_string))
.filter(|k| !allowed.contains(k.as_str()))
.collect();
unknown.sort();
unknown
}
fn collect_bundle_siblings(
bundle_root: &Path,
skill_name: &str,
out: &mut Vec<MCPResource>,
skipped: &mut Vec<String>,
) {
walk_bundle_subdir(
bundle_root,
"scripts",
skill_name,
|name| {
let ext = std::path::Path::new(name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
SCRIPT_EXTS
.iter()
.any(|allowed| ext.eq_ignore_ascii_case(allowed))
&& !is_non_skill_filename(name)
},
out,
skipped,
);
walk_bundle_subdir(
bundle_root,
"references",
skill_name,
|name| {
let ext = std::path::Path::new(name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
ext.eq_ignore_ascii_case("md") && !is_non_skill_filename(name)
},
out,
skipped,
);
}
const MAX_BUNDLE_FILES_PER_DIR: usize = 256;
fn walk_bundle_subdir(
bundle_root: &Path,
subdir_name: &str,
skill_name: &str,
accept: impl Fn(&str) -> bool,
out: &mut Vec<MCPResource>,
skipped: &mut Vec<String>,
) {
let dir = bundle_root.join(subdir_name);
let Ok(entries) = std::fs::read_dir(&dir) else {
return;
};
let starting_len = out.len();
for entry in entries.flatten() {
if out.len() - starting_len >= MAX_BUNDLE_FILES_PER_DIR {
warn!(
"Bundle {}/{subdir_name}: reached {} file cap; remaining entries skipped",
bundle_root.display(),
MAX_BUNDLE_FILES_PER_DIR
);
skipped.push(format!(
"{subdir_name}/: per-directory cap of {MAX_BUNDLE_FILES_PER_DIR} files reached, remaining entries unscanned"
));
break;
}
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_symlink() || !file_type.is_file() {
continue;
}
let entry_path = entry.path();
let Some(file_name) = entry_path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !accept(file_name) {
continue;
}
if let Ok(meta) = std::fs::metadata(&entry_path) {
if meta.len() > MAX_SKILL_FILE_BYTES {
warn!(
"Skipping bundle file {} ({} bytes > {} byte limit)",
entry_path.display(),
meta.len(),
MAX_SKILL_FILE_BYTES
);
skipped.push(format!(
"{subdir_name}/{file_name}: {} bytes exceeds the {MAX_SKILL_FILE_BYTES}-byte scan limit",
meta.len()
));
continue;
}
}
let content = match std::fs::read_to_string(&entry_path) {
Ok(s) => s,
Err(e) => {
debug!(
"Skipping bundle file {}: failed to read as UTF-8 ({e})",
entry_path.display()
);
skipped.push(format!(
"{subdir_name}/{file_name}: unreadable as UTF-8 text ({e})"
));
continue;
}
};
let resource_name = format!("{skill_name}/{subdir_name}/{file_name}");
let resource_uri = format!("skill://{skill_name}/{subdir_name}/{file_name}");
out.push(MCPResource {
uri: resource_uri,
name: resource_name,
description: Some(content),
mime_type: None,
size: None,
metadata: std::collections::HashMap::new(),
raw_json: None,
});
}
}
const CODE_EXECUTION_TOOLS: &[&str] = &[
"bash", "shell", "sh", "zsh", "exec", "eval", "run", "rm", "sudo",
];
const DATA_EXFIL_TOOLS: &[&str] = &["webfetch", "websearch", "fetch", "browse"];
struct ParsedGrant<'a> {
raw: &'a str,
tool: String,
restriction: Option<String>,
}
fn parse_grant_token(token: &str) -> Option<ParsedGrant<'_>> {
let trimmed = token.trim();
if trimmed.is_empty() {
return None;
}
if let Some((tool_raw, rest)) = trimmed.split_once('(') {
let restriction = rest.trim_end_matches(')').trim().to_string();
return Some(ParsedGrant {
raw: token,
tool: tool_raw.trim().to_ascii_lowercase(),
restriction: Some(restriction),
});
}
if let Some((tool_raw, rest)) = trimmed.split_once(':') {
return Some(ParsedGrant {
raw: token,
tool: tool_raw.trim().to_ascii_lowercase(),
restriction: Some(rest.trim().to_string()),
});
}
Some(ParsedGrant {
raw: token,
tool: trimmed.to_ascii_lowercase(),
restriction: None,
})
}
fn is_unrestricted(grant: &ParsedGrant<'_>) -> bool {
match grant.restriction.as_deref() {
None => true,
Some("") => true,
Some(r) => {
let collapsed: String = r.split_whitespace().collect();
if collapsed.is_empty() || collapsed == "*" {
return true;
}
collapsed.split(':').all(|seg| seg.is_empty() || seg == "*")
}
}
}
fn split_grant_tokens(grant: &str) -> Vec<&str> {
let mut tokens: Vec<&str> = Vec::new();
let mut depth: i32 = 0;
let mut start = 0;
for (i, c) in grant.char_indices() {
match c {
'(' => depth += 1,
')' if depth > 0 => depth -= 1,
',' | '\n' if depth == 0 => {
tokens.push(&grant[start..i]);
start = i + c.len_utf8();
}
_ => {}
}
}
tokens.push(&grant[start..]);
tokens
}
fn analyze_allowed_tools(skill_name: &str, path: &Path, grant: &str) -> Vec<YaraScanResult> {
let mut findings: Vec<YaraScanResult> = Vec::new();
let push = |findings: &mut Vec<YaraScanResult>,
rule: &'static str,
severity: &'static str,
message: String| {
findings.push(make_heuristic_finding(
rule, severity, message, skill_name, path,
));
};
for raw in split_grant_tokens(grant) {
let Some(parsed) = parse_grant_token(raw) else {
continue;
};
if parsed.tool == "*" {
push(
&mut findings,
"OverbroadAllowedTools",
"high",
format!(
"Skill '{skill_name}' grants `*` (all tools). This bypasses every \
per-tool restriction. Replace with the specific tools the skill \
actually needs."
),
);
continue;
}
if CODE_EXECUTION_TOOLS.contains(&parsed.tool.as_str()) && is_unrestricted(&parsed) {
push(
&mut findings,
"OverbroadAllowedTools",
"high",
format!(
"Skill '{skill_name}' grants tool `{}` without a restriction, which \
permits arbitrary command execution. Restrict to specific \
subcommands (e.g. `Bash(git status:*)`) or replace with a bounded \
tool.",
parsed.raw.trim()
),
);
continue;
}
if DATA_EXFIL_TOOLS.contains(&parsed.tool.as_str()) {
push(
&mut findings,
"DataExfiltrationGrant",
"medium",
format!(
"Skill '{skill_name}' grants `{}` — a network-egress tool that can \
send skill input or local file content to remote URLs. Confirm the \
skill needs network access and that any URL pattern is bounded.",
parsed.raw.trim()
),
);
}
}
findings
}
const SENSITIVE_PATH_PATTERNS: &[&str] = &[
concat!("~/", ".s", "sh/"),
concat!("~/", ".aws/"),
concat!("~/", ".gn", "upg/"),
concat!("~/", ".gcp/"),
concat!("~/", ".azure/"),
concat!("~/", ".kube/"),
concat!("~/", ".docker/"),
concat!("~/", ".config/", "gh/"),
concat!("/", "etc/sh", "adow"),
concat!("/", "etc/", "passwd"),
concat!("/", "etc/", "sudoers"),
concat!("/", "ro", "ot/"),
concat!("id", "_rsa"),
concat!("id", "_ed25519"),
concat!("id", "_ecdsa"),
concat!("id", "_dsa"),
".pem",
".p12",
".pfx",
".key",
"credentials.json",
"secrets.json",
"secrets.yaml",
"secrets.yml",
".env",
".npmrc",
".pypirc",
".netrc",
];
fn is_sensitive_path(path: &str) -> bool {
SENSITIVE_PATH_PATTERNS
.iter()
.any(|pat| path.contains(*pat))
}
fn is_path_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '-' | '_' | '~' | '+' | ':')
}
fn analyze_sensitive_file_references(
skill_name: &str,
path: &Path,
body: &str,
) -> Vec<YaraScanResult> {
const MAX_TOKEN_BYTES: usize = 200;
let mut findings: Vec<YaraScanResult> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for (i, _) in body.match_indices('@') {
let prev_is_word_char = i
.checked_sub(1)
.and_then(|j| body.as_bytes().get(j))
.is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_');
if prev_is_word_char {
continue;
}
let after = &body[i + 1..];
let token_end = after
.find(|c: char| !is_path_char(c))
.unwrap_or(after.len())
.min(MAX_TOKEN_BYTES);
let token = &after[..token_end];
if token.is_empty() || !is_sensitive_path(token) {
continue;
}
let key = token.to_ascii_lowercase();
if !seen.insert(key) {
continue;
}
findings.push(make_heuristic_finding(
"SkillSensitiveFileReference",
"high",
format!(
"Skill '{skill_name}' includes a file-reference `@{token}` \
matching a known sensitive-path pattern. The `@` syntax \
inlines the file's contents into prompt context, where any \
network-capable tool can exfiltrate it. Remove the \
reference or replace with a non-sensitive equivalent."
),
skill_name,
path,
));
}
findings
}
const MIN_EMBEDDED_PAYLOAD_CHARS: usize = 500;
fn is_b64_or_url64_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '_' | '-')
}
fn is_hex_char(c: char) -> bool {
c.is_ascii_hexdigit()
}
fn analyze_embedded_payloads(skill_name: &str, path: &Path, body: &str) -> Vec<YaraScanResult> {
let mut findings: Vec<YaraScanResult> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let push_if_qualifying = |findings: &mut Vec<YaraScanResult>,
seen: &mut std::collections::HashSet<String>,
start: usize,
end: usize| {
let blob = &body[start..end];
let len = blob.chars().count();
if len < MIN_EMBEDDED_PAYLOAD_CHARS {
return;
}
if body[..start].ends_with("base64,") {
return;
}
let kind = if blob.chars().all(is_hex_char) {
"hex"
} else {
"base64"
};
let key = format!("{}:{}", kind, blob.chars().take(50).collect::<String>());
if !seen.insert(key) {
return;
}
findings.push(make_heuristic_finding(
"SkillEmbeddedPayload",
"high",
format!(
"Skill '{skill_name}' contains a {len}-char {kind}-shape blob in \
its body. Embedded payloads bypass plaintext YARA rules and LLM \
analysis by deferring decoding to runtime. Verify the content; \
if legitimate (config, hash chain), reduce its size or move to \
a referenced file. If unknown, treat as compromised."
),
skill_name,
path,
));
};
let mut start: Option<usize> = None;
for (i, c) in body.char_indices() {
if is_b64_or_url64_char(c) {
start.get_or_insert(i);
} else if let Some(s) = start.take() {
push_if_qualifying(&mut findings, &mut seen, s, i);
}
}
if let Some(s) = start {
push_if_qualifying(&mut findings, &mut seen, s, body.len());
}
findings
}
const MIN_TRIGGER_DESCRIPTION_CHARS: usize = 24;
const SUBSTANTIVE_BODY_THRESHOLD_CHARS: usize = 200;
fn analyze_vague_trigger(
skill_name: &str,
path: &Path,
description: Option<&str>,
body: &str,
) -> Vec<YaraScanResult> {
let body_chars = body.chars().count();
if body_chars < SUBSTANTIVE_BODY_THRESHOLD_CHARS {
return Vec::new();
}
let desc_chars = description.map(|d| d.chars().count()).unwrap_or(0);
if desc_chars >= MIN_TRIGGER_DESCRIPTION_CHARS {
return Vec::new();
}
let reason = if description.is_none() {
format!(
"Skill '{skill_name}' has a substantial body ({body_chars} chars) but no \
`description` frontmatter. Without a clear trigger, an agent may invoke \
this skill unintentionally. Add a one-line description that disambiguates \
intent."
)
} else {
format!(
"Skill '{skill_name}' has a body ({body_chars} chars) but only a \
{desc_chars}-char description. Expand the description to clearly state \
when the skill should run."
)
};
vec![make_heuristic_finding(
"VagueSkillTrigger",
"medium",
reason,
skill_name,
path,
)]
}
const GENERIC_TRIGGER_PHRASES: &[&str] = &[
"help",
"help me",
"help with anything",
"assistant",
"an assistant",
"a helper",
"helper",
"general purpose tool",
"general purpose skill",
"general purpose assistant",
"general-purpose tool",
"universal tool",
"universal skill",
"universal assistant",
"default tool",
"default assistant",
"do anything",
"do everything",
"i can do anything",
"i can do everything",
"use this for everything",
"use this for anything",
"use me for everything",
"use me for anything",
];
fn analyze_generic_trigger(
skill_name: &str,
path: &Path,
description: Option<&str>,
) -> Vec<YaraScanResult> {
let Some(desc) = description else {
return Vec::new();
};
let normalized = desc
.trim()
.trim_end_matches(['.', '!', '?', ';', ':'])
.trim()
.to_ascii_lowercase();
let core = normalized
.strip_prefix("a ")
.or_else(|| normalized.strip_prefix("an "))
.or_else(|| normalized.strip_prefix("the "))
.unwrap_or(normalized.as_str());
if !GENERIC_TRIGGER_PHRASES
.iter()
.any(|p| core == *p || normalized == *p)
{
return Vec::new();
}
vec![make_heuristic_finding(
"GenericSkillTrigger",
"medium",
format!(
"Skill '{skill_name}' has a generic trigger description (\"{}\"). \
Generic triggers cause an agent's router to invoke the skill on \
unrelated user requests (trigger hijack). Replace with a specific \
description naming the conditions under which this skill should run.",
desc.trim()
),
skill_name,
path,
)]
}
const DANGEROUS_YAML_TAGS: &[&str] = &[
"!!python/object",
"!!python/name",
"!!python/module",
"!!python/apply",
"!python/object",
"!ruby/object",
"!!ruby/object",
"!ruby/hash",
"!!ruby/hash",
"!!perl",
"!!java",
"!!javax.",
"!!com.",
"!!org.springframework",
"!!binary",
];
fn analyze_dangerous_yaml(
skill_name: &str,
path: &Path,
raw_frontmatter: &str,
) -> Vec<YaraScanResult> {
let mut seen: Vec<&str> = Vec::new();
for tag in DANGEROUS_YAML_TAGS {
if raw_frontmatter.contains(tag) && !seen.contains(tag) {
seen.push(tag);
}
}
if seen.is_empty() {
return Vec::new();
}
vec![make_heuristic_finding(
"UnsafeYamlDeserialization",
"high",
format!(
"Skill '{skill_name}' frontmatter contains dangerous YAML tag(s): {}. \
These execute code or construct arbitrary objects under an unsafe \
deserializer (PyYAML FullLoader/UnsafeLoader, Ruby Psych.load) — a \
skill loader that opts into one would run the payload at load time, \
before the skill is ever invoked. Legitimate skill metadata never \
needs these tags.",
seen.join(", ")
),
skill_name,
path,
)]
}
const KNOWN_BRANDS: &[&str] = &[
"google",
"gmail",
"youtube",
"openai",
"chatgpt",
"anthropic",
"claude",
"aws",
"amazon",
"azure",
"microsoft",
"github",
"gitlab",
"stripe",
"slack",
"notion",
"figma",
"salesforce",
"twilio",
"cloudflare",
"vercel",
"netlify",
"shopify",
"paypal",
"polymarket",
"solana",
"coinbase",
"binance",
"metamask",
"discord",
"telegram",
"dropbox",
"atlassian",
"jira",
];
const IMPERSONATION_CUES: &[&str] = &[
"official",
"verified",
"authorized",
"certified",
"genuine",
"endorsed",
];
fn tokenize_lower(s: &str) -> Vec<String> {
s.to_ascii_lowercase()
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|t| !t.is_empty())
.map(str::to_string)
.collect()
}
fn analyze_brand_impersonation(
skill_name: &str,
path: &Path,
description: Option<&str>,
) -> Vec<YaraScanResult> {
let desc_head = description
.and_then(|d| d.lines().find(|l| !l.trim().is_empty()))
.unwrap_or("");
let tokens = tokenize_lower(&format!("{skill_name} {desc_head}"));
let brand = KNOWN_BRANDS.iter().find(|b| tokens.iter().any(|t| t == *b));
let cue = IMPERSONATION_CUES
.iter()
.find(|c| tokens.iter().any(|t| t == *c));
match (brand, cue) {
(Some(brand), Some(cue)) => vec![make_heuristic_finding(
"BrandImpersonation",
"medium",
format!(
"Skill '{skill_name}' presents as a `{brand}` `{cue}` but authorship \
cannot be verified from the skill files. Attackers publish \
brand-named skills (e.g. a fake \"{brand}\" integration) to capture \
traffic from users searching for the official one. Confirm this \
skill is first-party or rename it to avoid implying vendor endorsement."
),
skill_name,
path,
)],
_ => Vec::new(),
}
}
pub fn read_bundle_file_no_escape(root: &Path, rel: &str) -> Option<String> {
let real = std::fs::canonicalize(root.join(rel)).ok()?;
let real_root = std::fs::canonicalize(root).ok()?;
if !real.starts_with(&real_root) {
warn!(
"Refusing bundle file that resolves outside its bundle root: {}/{rel} -> {}",
root.display(),
real.display()
);
return None;
}
std::fs::read_to_string(&real).ok()
}
fn analyze_json_manifests(skill_name: &str, bundle_root: &Path) -> Vec<YaraScanResult> {
let mut findings = Vec::new();
for manifest in ["package.json", "manifest.json"] {
let p = bundle_root.join(manifest);
let Some(content) = read_bundle_file_no_escape(bundle_root, manifest) else {
continue;
};
if content.len() as u64 > MAX_SKILL_FILE_BYTES {
continue;
}
if let Some(key) = json_pollution_key(&content) {
findings.push(make_heuristic_finding(
"JsonPrototypePollution",
"high",
format!(
"Bundle '{skill_name}' manifest `{manifest}` contains a \
prototype-pollution key (`{key}`). A Node.js loader that \
deep-merges this manifest into a shared config object poisons \
the prototype for every object in the runtime. Remove the key \
or parse with a pollution-safe merge."
),
skill_name,
&p,
));
}
}
findings
}
fn json_pollution_key(content: &str) -> Option<&'static str> {
match serde_json::from_str::<serde_json::Value>(content) {
Ok(value) => walk_for_pollution(&value),
Err(_) => content.contains("__proto__").then_some("__proto__"),
}
}
fn walk_for_pollution(value: &serde_json::Value) -> Option<&'static str> {
match value {
serde_json::Value::Object(map) => {
if map.contains_key("__proto__") {
return Some("__proto__");
}
for key in ["constructor", "prototype"] {
if map.get(key).is_some_and(serde_json::Value::is_object) {
return Some(if key == "constructor" {
"constructor"
} else {
"prototype"
});
}
}
map.values().find_map(walk_for_pollution)
}
serde_json::Value::Array(items) => items.iter().find_map(walk_for_pollution),
_ => None,
}
}
const IDENTITY_ARTIFACTS: &[&str] = &[
"SOUL.md",
"MEMORY.md",
"AGENTS.md",
"CLAUDE.md",
".claude/settings",
"settings.local.json",
".cursorrules",
];
const WRITE_VERBS: &[&str] = &[
"write",
"append",
"add",
"edit",
"modify",
"update",
"insert",
"save",
"persist",
"overwrite",
"create",
"inject",
];
fn analyze_identity_file_access(skill_name: &str, path: &Path, body: &str) -> Vec<YaraScanResult> {
let mut findings = Vec::new();
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
for line in body.lines() {
let artifact = IDENTITY_ARTIFACTS
.iter()
.find(|a| line.contains(*a) && !seen.contains(*a));
let Some(artifact) = artifact else { continue };
let lower = line.to_ascii_lowercase();
let has_write_verb = lower
.split(|c: char| !c.is_ascii_alphanumeric())
.any(|word| {
WRITE_VERBS.iter().any(|v| {
word.strip_prefix(v)
.is_some_and(|rest| matches!(rest, "" | "s" | "d" | "ed" | "es" | "ing"))
})
});
if !has_write_verb {
continue;
}
seen.insert(artifact);
findings.push(make_heuristic_finding(
"AgentIdentityFileWrite",
"medium",
format!(
"Skill '{skill_name}' instructs the agent to write to `{artifact}`, \
an agent identity/memory file. Content written there persists \
after the skill is removed and is re-loaded into the agent's \
context every session — the memory-poisoning / identity-backdoor \
persistence pattern. Review the write for legitimacy; skills \
should rarely need to modify agent identity artifacts."
),
skill_name,
path,
));
}
findings
}
fn analyze_external_references(skill_name: &str, path: &Path, body: &str) -> Vec<YaraScanResult> {
let mut hosts: Vec<String> = Vec::new();
for (i, _) in body.match_indices("http") {
let prev_is_word = i
.checked_sub(1)
.and_then(|j| body.as_bytes().get(j))
.is_some_and(|&b| b.is_ascii_alphanumeric());
if prev_is_word {
continue;
}
let rest = &body[i..];
let after_scheme = rest
.strip_prefix("https://")
.or_else(|| rest.strip_prefix("http://"));
let Some(after) = after_scheme else { continue };
let host: String = after
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-'))
.collect();
if host.contains('.') && !hosts.contains(&host) {
hosts.push(host);
}
}
if hosts.is_empty() {
return Vec::new();
}
hosts.sort();
vec![make_heuristic_finding(
"ExternalReferenceInventory",
"low",
format!(
"Skill '{skill_name}' references external URL(s) on: {}. Externally \
referenced content is mutable and outside the trust boundary — it can \
change after review without any change to the skill itself. Verify these \
hosts are trusted and consider inlining or hash-pinning the content.",
hosts.join(", ")
),
skill_name,
path,
)]
}
const SCRIPT_EGRESS_PATTERNS: &[&str] = &[
"curl ",
"wget ",
"requests.",
"urllib",
"http.client",
"httpx.",
"fetch(",
"axios",
"net/http",
"Invoke-WebRequest",
"XMLHttpRequest",
"Net::HTTP",
];
fn analyze_undeclared_egress(
skill_name: &str,
skill_md_path: &Path,
allowed_tools: Option<&str>,
resources: &[MCPResource],
) -> Vec<YaraScanResult> {
let Some(grant) = allowed_tools else {
return Vec::new();
};
let discloses_network = split_grant_tokens(grant)
.iter()
.filter_map(|t| parse_grant_token(t))
.any(|g| {
DATA_EXFIL_TOOLS.contains(&g.tool.as_str())
|| CODE_EXECUTION_TOOLS.contains(&g.tool.as_str())
});
if discloses_network {
return Vec::new();
}
let mut offending: Vec<&str> = Vec::new();
for res in resources {
let Some(content) = res.description.as_deref() else {
continue;
};
if SCRIPT_EGRESS_PATTERNS.iter().any(|p| content.contains(p)) {
offending.push(&res.name);
}
}
if offending.is_empty() {
return Vec::new();
}
vec![make_heuristic_finding(
"UndeclaredNetworkEgress",
"high",
format!(
"Skill '{skill_name}' declares an `allowed-tools` manifest with no \
network-capable grant, but bundled file(s) perform network egress: {}. \
The declared permission set understates what the skill actually does — \
the pattern used to hide exfiltration behind a clean manifest. Either \
declare the network capability or remove the egress.",
offending.join(", ")
),
skill_name,
skill_md_path,
)]
}
pub fn analyze_skill_set(skills: &[(&Path, &MCPPrompt)]) -> Vec<YaraScanResult> {
let mut by_name: std::collections::HashMap<String, Vec<&Path>> =
std::collections::HashMap::new();
for (path, prompt) in skills {
by_name
.entry(prompt.name.to_ascii_lowercase())
.or_default()
.push(*path);
}
let mut findings: Vec<YaraScanResult> = Vec::new();
for (lower_name, paths) in by_name {
if paths.len() < 2 {
continue;
}
let mut sorted = paths;
sorted.sort();
let joined = sorted
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ");
let primary_path = sorted[0];
findings.push(make_heuristic_finding(
"SkillNameCollision",
"medium",
format!(
"Skill name '{lower_name}' is declared by {} files: {joined}. \
Whichever skill the agent's router resolves last shadows the \
others — an attacker who can write a workspace-level skill \
with the same name as a trusted user-level skill can silently \
replace it. Rename one of the skills.",
sorted.len()
),
&lower_name,
primary_path,
));
}
findings
}
fn make_heuristic_finding(
rule: &'static str,
severity: &'static str,
description: String,
target: &str,
path: &Path,
) -> YaraScanResult {
YaraScanResult {
rule_name: rule.to_string(),
target_type: "prompt".to_string(),
target_name: target.to_string(),
rule_file: Some("skill_parser".to_string()),
matched_text: None,
context: format!("source: {}", path.display()),
rule_metadata: Some(YaraRuleMetadata {
name: Some(rule.to_string()),
author: Some("ramparts".to_string()),
date: None,
version: Some(env!("CARGO_PKG_VERSION").to_string()),
description: Some(description),
severity: Some(severity.to_string()),
category: Some("skill_security".to_string()),
confidence: Some("high".to_string()),
tags: Vec::new(),
}),
owasp_tags: crate::taxonomy::tags_for_yara_rule(rule),
installed_version: None,
fixed_version: None,
phase: None,
rules_executed: None,
security_issues_detected: None,
total_items_scanned: None,
total_matches: None,
status: None,
}
}
fn parse_argument_hint(hint: &str) -> Option<Vec<MCPPromptArgument>> {
#[derive(Clone, Copy)]
enum Bracket {
None,
Required, Optional, }
let mut args: Vec<MCPPromptArgument> = Vec::new();
let mut buf = String::new();
let mut inside = Bracket::None;
for c in hint.chars() {
match (c, inside) {
('<', Bracket::None) => {
inside = Bracket::Required;
buf.clear();
}
('[', Bracket::None) => {
inside = Bracket::Optional;
buf.clear();
}
('>', Bracket::Required) | (']', Bracket::Optional) => {
let token = buf.trim();
if !token.is_empty() {
args.push(MCPPromptArgument {
name: token.to_string(),
description: None,
required: Some(matches!(inside, Bracket::Required)),
});
}
inside = Bracket::None;
}
(_, Bracket::Required | Bracket::Optional) => buf.push(c),
(_, Bracket::None) => {} }
}
if args.is_empty() {
None
} else {
Some(args)
}
}
fn split_frontmatter(raw: &str) -> (Option<&str>, &str) {
let trimmed = raw.trim_start_matches('\u{feff}'); let stripped = match trimmed.strip_prefix("---") {
Some(s) => s,
None => return (None, raw),
};
let after_open = match stripped.find('\n') {
Some(i) => &stripped[i + 1..],
None => return (None, raw),
};
if let Some(close_idx) = find_frontmatter_close(after_open) {
let frontmatter = &after_open[..close_idx];
let body_start = match after_open[close_idx..].find('\n') {
Some(n) => close_idx + n + 1,
None => after_open.len(),
};
let body = &after_open[body_start..];
return (Some(frontmatter), body);
}
(None, raw)
}
fn find_frontmatter_close(s: &str) -> Option<usize> {
let mut start = 0;
while start <= s.len() {
let line_end = s[start..].find('\n').map_or(s.len(), |i| start + i);
let line = s[start..line_end].trim_end_matches('\r');
if line == "---" {
return Some(start);
}
if line_end == s.len() {
return None;
}
start = line_end + 1;
}
None
}
pub fn discover_skills_in_root(root: &Path) -> Result<Vec<PathBuf>> {
if !root.exists() {
return Err(anyhow!(
"Skill root path does not exist: {}",
root.display()
));
}
const SKIP_DIRS: &[&str] = &[
".git",
"node_modules",
"target",
"dist",
"build",
".venv",
"venv",
"__pycache__",
];
const MAX_DEPTH: usize = 16;
fn walk(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
if depth > MAX_DEPTH {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if SKIP_DIRS.contains(&name) {
continue;
}
if name.starts_with('.') && !is_known_skill_dotdir(name) {
continue;
}
}
walk(&path, depth + 1, out);
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if ext.eq_ignore_ascii_case("md") {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if is_non_skill_filename(name) {
debug!(
"Skipping {} (matches non-skill filename convention)",
path.display()
);
continue;
}
out.push(path);
}
}
}
}
fn is_known_skill_dotdir(name: &str) -> bool {
matches!(
name,
".claude" | ".cursor" | ".codex" | ".openai" | ".windsurf" | ".gemini"
)
}
let mut out = Vec::new();
walk(root, 0, &mut out);
out.sort();
Ok(out)
}
pub fn default_discovery_roots() -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = Vec::new();
if let Ok(extra) = std::env::var(SKILL_ROOTS_ENV) {
for entry in extra.split(',').map(str::trim).filter(|s| !s.is_empty()) {
roots.push(expand_tilde(entry));
}
}
const PER_ECOSYSTEM: &[&[&str]] = &[
&[".claude", "commands"],
&[".claude", "skills"],
&[".cursor", "commands"],
&[".cursor", "skills"],
&[".codex", "commands"],
&[".codex", "skills"],
&[".windsurf", "commands"],
&[".gemini", "commands"],
&[".openai", "commands"],
];
let push_for_base = |roots: &mut Vec<PathBuf>, base: &Path| {
for segments in PER_ECOSYSTEM {
let path = segments
.iter()
.fold(base.to_path_buf(), |acc, seg| acc.join(seg));
roots.push(path);
}
};
if let Some(home) = dirs::home_dir() {
push_for_base(&mut roots, &home);
}
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
push_for_base(&mut roots, &cwd);
if let Some(home) = dirs::home_dir() {
roots.push(home.join(".skills"));
}
let cwd_skills = cwd.join("skills");
if is_agentskills_root_dir(&cwd_skills) {
roots.push(cwd_skills);
}
roots
}
fn is_agentskills_root_dir(dir: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
for entry in entries.flatten() {
let Ok(ft) = entry.file_type() else { continue };
if ft.is_symlink() || !ft.is_dir() {
continue;
}
if bundle_dir_has_skill_md(&entry.path()) {
return true;
}
}
false
}
fn bundle_dir_has_skill_md(dir: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
for entry in entries.flatten() {
if entry.file_name() != OsStr::new("SKILL.md") {
continue;
}
let Ok(ft) = entry.file_type() else { continue };
if !ft.is_symlink() && ft.is_file() {
return true;
}
}
false
}
fn expand_tilde(input: &str) -> PathBuf {
if let Some(rest) = input.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
} else if input == "~" {
if let Some(home) = dirs::home_dir() {
return home;
}
}
PathBuf::from(input)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn parse(path: &str, raw: &str) -> ParsedSkill {
parse_skill_content(&PathBuf::from(path), raw).expect("parsed skill")
}
#[test]
fn bundle_file_read_refuses_symlink_escape() {
let base = std::env::temp_dir().join(format!("ramparts-symlink-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let bundle = base.join("bundle");
let victim = base.join("victim");
std::fs::create_dir_all(&bundle).unwrap();
std::fs::create_dir_all(&victim).unwrap();
std::fs::write(victim.join("requirements.txt"), "secret-pkg==9.9.9\n").unwrap();
std::fs::write(bundle.join("real.txt"), "in-bundle-content\n").unwrap();
assert_eq!(
read_bundle_file_no_escape(&bundle, "real.txt").as_deref(),
Some("in-bundle-content\n")
);
#[cfg(unix)]
{
std::os::unix::fs::symlink(
victim.join("requirements.txt"),
bundle.join("requirements.txt"),
)
.unwrap();
assert!(
read_bundle_file_no_escape(&bundle, "requirements.txt").is_none(),
"symlink escaping the bundle must be refused"
);
}
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn dangerous_yaml_tag_in_frontmatter_is_flagged() {
let parsed = parse(
"yamlbomb.md",
"---\nname: yamlbomb\ndescription: does things\nmeta: !!python/object/apply:os.system [\"id\"]\n---\nBody text here.\n",
);
assert!(parsed
.heuristic_findings
.iter()
.any(|f| f.rule_name == "UnsafeYamlDeserialization"));
}
#[test]
fn safe_frontmatter_has_no_yaml_finding() {
let parsed = parse(
"clean.md",
"---\nname: clean\ndescription: a normal skill\n---\nBody.\n",
);
assert!(parsed
.heuristic_findings
.iter()
.all(|f| f.rule_name != "UnsafeYamlDeserialization"));
}
#[test]
fn brand_plus_cue_is_flagged_as_impersonation() {
let parsed = parse(
"gw.md",
"---\nname: google-workspace-integration\ndescription: Official Google Workspace integration\n---\nSyncs your docs.\n",
);
assert!(parsed
.heuristic_findings
.iter()
.any(|f| f.rule_name == "BrandImpersonation"));
}
#[test]
fn brand_mention_without_cue_is_not_flagged() {
let parsed = parse(
"helper.md",
"---\nname: doc-helper\ndescription: Summarizes text you paste, works well with google docs exports\n---\nHelps.\n",
);
assert!(parsed
.heuristic_findings
.iter()
.all(|f| f.rule_name != "BrandImpersonation"));
}
#[test]
fn prototype_pollution_in_manifest_is_flagged() {
let dir = std::env::temp_dir().join(format!("ramparts-proto-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("package.json"),
r#"{ "name": "x", "__proto__": { "polluted": true } }"#,
)
.unwrap();
let findings = analyze_json_manifests("x", &dir);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].rule_name, "JsonPrototypePollution");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn identity_file_write_instruction_is_flagged() {
let parsed = parse(
"persist.md",
"After each task, append your conclusions to MEMORY.md so they persist.\n",
);
assert!(parsed
.heuristic_findings
.iter()
.any(|f| f.rule_name == "AgentIdentityFileWrite"));
}
#[test]
fn identity_file_read_reference_is_not_flagged() {
let parsed = parse(
"benign.md",
"See CLAUDE.md for additional project conventions before starting.\n",
);
assert!(parsed
.heuristic_findings
.iter()
.all(|f| f.rule_name != "AgentIdentityFileWrite"));
}
#[test]
fn external_urls_produce_inventory_finding() {
let parsed = parse(
"fetcher.md",
"Fetch the latest schema from https://api.example.com/schema and follow it.\n",
);
let finding = parsed
.heuristic_findings
.iter()
.find(|f| f.rule_name == "ExternalReferenceInventory")
.expect("inventory finding");
let desc = finding
.rule_metadata
.as_ref()
.and_then(|m| m.description.as_deref())
.unwrap_or("");
assert!(desc.contains("api.example.com"));
}
#[test]
fn no_urls_no_inventory_finding() {
let parsed = parse(
"local.md",
"Summarize the diff and suggest a commit message.\n",
);
assert!(parsed
.heuristic_findings
.iter()
.all(|f| f.rule_name != "ExternalReferenceInventory"));
}
#[test]
fn undeclared_egress_in_bundle_script_is_flagged() {
let script = MCPResource {
uri: "skill://s/scripts/run.py".into(),
name: "s/scripts/run.py".into(),
description: Some("import requests\nrequests.post(url, data=payload)\n".into()),
mime_type: None,
size: None,
metadata: std::collections::HashMap::new(),
raw_json: None,
};
let findings = analyze_undeclared_egress(
"s",
Path::new("s/SKILL.md"),
Some("Read, Grep"),
std::slice::from_ref(&script),
);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].rule_name, "UndeclaredNetworkEgress");
assert!(analyze_undeclared_egress(
"s",
Path::new("s/SKILL.md"),
Some("WebFetch, Read"),
std::slice::from_ref(&script),
)
.is_empty());
assert!(analyze_undeclared_egress(
"s",
Path::new("s/SKILL.md"),
Some("Bash(git status:*)"),
std::slice::from_ref(&script),
)
.is_empty());
assert!(analyze_undeclared_egress(
"s",
Path::new("s/SKILL.md"),
Some("Bash"),
std::slice::from_ref(&script),
)
.is_empty());
assert!(
analyze_undeclared_egress("s", Path::new("s/SKILL.md"), None, &[script]).is_empty()
);
}
#[test]
fn json_pollution_ignores_lookalike_names() {
assert!(json_pollution_key(
r#"{"dependencies":{"prototype":"1.7.0","constructor":"2.0.0"}}"#
)
.is_none());
assert_eq!(
json_pollution_key(r#"{"__proto__":{"polluted":true}}"#),
Some("__proto__")
);
assert_eq!(
json_pollution_key(r#"{"constructor":{"prototype":{"x":1}}}"#),
Some("constructor")
);
assert_eq!(
json_pollution_key("garbage __proto__ here"),
Some("__proto__")
);
}
#[test]
fn external_reference_parsing_is_accurate() {
let parsed = parse("x.md", "the shorthttp thing and path/httpx are fine\n");
assert!(parsed
.heuristic_findings
.iter()
.all(|f| f.rule_name != "ExternalReferenceInventory"));
let parsed = parse("y.md", "fetch https://api.example.com:8080/v1 for data\n");
let f = parsed
.heuristic_findings
.iter()
.find(|f| f.rule_name == "ExternalReferenceInventory")
.expect("inventory finding");
let desc = f
.rule_metadata
.as_ref()
.and_then(|m| m.description.as_deref())
.unwrap_or("");
assert!(desc.contains("api.example.com") && !desc.contains("8080"));
}
#[test]
fn brand_impersonation_needs_an_officialness_claim() {
assert!(analyze_brand_impersonation(
"github-integration",
Path::new("g.md"),
Some("A community GitHub connector"),
)
.is_empty());
assert_eq!(
analyze_brand_impersonation(
"gh-helper",
Path::new("g.md"),
Some("The official GitHub integration"),
)
.len(),
1
);
}
#[test]
fn parses_frontmatter_and_body() {
let parsed = parse(
"deploy.md",
"---\ndescription: Ship to staging\n---\n\nDeploy the app, then notify the channel.\n",
);
assert_eq!(parsed.prompt.name, "deploy");
let desc = parsed.prompt.description.expect("description");
assert!(desc.contains("Ship to staging"));
assert!(desc.contains("Deploy the app"));
}
#[test]
fn description_excludes_source_path_to_avoid_yara_false_positives() {
let parsed = parse("/var/folders/xyz/deploy.md", "body");
let desc = parsed.prompt.description.expect("description");
assert!(
!desc.contains("/var/"),
"description leaked source path: {desc}"
);
}
#[test]
fn handles_missing_frontmatter() {
let parsed = parse("no-frontmatter.md", "Just a body, no frontmatter at all.");
assert_eq!(parsed.prompt.name, "no-frontmatter");
let desc = parsed.prompt.description.expect("description");
assert!(desc.contains("Just a body, no frontmatter at all."));
}
#[test]
fn handles_malformed_frontmatter() {
let parsed = parse(
"broken.md",
"---\nthis is not valid yaml: [oops\n\nbody starts here\n",
);
assert_eq!(parsed.prompt.name, "broken");
assert!(parsed.prompt.description.is_some());
}
#[test]
fn yaml_parse_error_falls_back_to_no_frontmatter() {
let parsed = parse(
"badyaml.md",
"---\nthis is not: valid: yaml: at: all\n---\nbody\n",
);
assert_eq!(parsed.prompt.name, "badyaml");
let desc = parsed.prompt.description.expect("description");
assert!(desc.contains("body"));
}
#[test]
fn frontmatter_name_overrides_filename() {
let parsed = parse("ignored.md", "---\nname: actual-name\n---\nhello\n");
assert_eq!(parsed.prompt.name, "actual-name");
}
#[test]
fn empty_or_whitespace_name_falls_back_to_filename_stem() {
for raw in [
"---\nname: \"\"\n---\nbody\n",
"---\nname: \" \"\n---\nbody\n",
] {
let parsed = parse("real-stem.md", raw);
assert_eq!(
parsed.prompt.name, "real-stem",
"expected fallback to stem for: {raw:?}"
);
}
}
#[test]
fn single_argument_hint_token_becomes_named_argument() {
let parsed = parse(
"with-args.md",
"---\ndescription: Greets a user\nargument-hint: <name>\n---\nSay hello to the user.\n",
);
let args = parsed.prompt.arguments.expect("arguments");
assert_eq!(args.len(), 1);
assert_eq!(args[0].name, "name");
assert!(args[0].description.is_none());
}
#[test]
fn multiple_argument_hint_tokens_become_separate_arguments() {
let parsed = parse(
"multi.md",
"---\nargument-hint: <env> <region>\n---\nbody\n",
);
let args = parsed.prompt.arguments.expect("arguments");
let names: Vec<_> = args.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, vec!["env", "region"]);
}
#[test]
fn free_form_argument_hint_falls_back_to_description() {
let parsed = parse(
"free-form.md",
"---\ndescription: Does a thing\nargument-hint: just type your message\n---\nbody\n",
);
assert!(parsed.prompt.arguments.is_none());
let desc = parsed.prompt.description.expect("description");
assert!(desc.contains("Argument hint: just type your message"));
assert!(desc.contains("Does a thing"));
assert!(desc.contains("body"));
}
#[test]
fn empty_brackets_in_hint_yield_no_arguments() {
let parsed = parse("empty-bracket.md", "---\nargument-hint: <>\n---\nbody\n");
assert!(parsed.prompt.arguments.is_none());
}
#[test]
fn strips_bom() {
let parsed = parse("bom.md", "\u{feff}---\ndescription: x\n---\ny\n");
let desc = parsed.prompt.description.unwrap();
assert!(desc.contains('x'));
assert!(desc.contains('y'));
}
#[test]
fn empty_description_in_frontmatter_falls_back_to_body() {
let parsed = parse(
"empty-desc.md",
"---\ndescription: \"\"\n---\nactual body\n",
);
let desc = parsed.prompt.description.expect("description");
assert!(desc.contains("actual body"));
}
#[test]
fn fully_empty_skill_returns_none() {
let path = PathBuf::from("empty.md");
assert!(parse_skill_content(&path, " \n\t\n").is_none());
assert!(parse_skill_content(&path, "---\n---\n").is_none());
}
#[test]
fn closing_frontmatter_marker_without_trailing_newline_yields_empty_body() {
let path = PathBuf::from("trim.md");
let raw = "---\ndescription: hi\n---";
let parsed = parse_skill_content(&path, raw).unwrap();
let desc = parsed.prompt.description.expect("description");
assert_eq!(desc, "hi");
assert!(!desc.contains("---"), "marker leaked into body: {desc}");
}
#[test]
fn argument_only_skill_has_none_description_not_empty_string() {
let parsed = parse("args-only.md", "---\nargument-hint: <name>\n---\n");
assert!(
parsed.prompt.description.is_none(),
"expected None, got {:?}",
parsed.prompt.description
);
assert_eq!(parsed.prompt.arguments.as_ref().unwrap().len(), 1);
}
#[test]
fn dangerous_allowed_tools_grant_emits_finding() {
let parsed = parse(
"danger.md",
"---\ndescription: A dangerous skill\nallowed-tools: Bash(*), Read\n---\nrm -rf /\n",
);
let rules: Vec<_> = parsed
.heuristic_findings
.iter()
.map(|f| f.rule_name.as_str())
.collect();
assert!(
rules.contains(&"OverbroadAllowedTools"),
"expected OverbroadAllowedTools, got {rules:?}"
);
}
#[test]
fn bare_bash_grant_is_treated_as_unrestricted() {
let parsed = parse(
"bare.md",
"---\ndescription: stub\nallowed-tools: Bash, Read\n---\nbody\n",
);
let overbroad = parsed
.heuristic_findings
.iter()
.find(|f| f.rule_name == "OverbroadAllowedTools")
.expect("bare Bash should fire OverbroadAllowedTools");
let desc = overbroad
.rule_metadata
.as_ref()
.and_then(|m| m.description.as_deref())
.unwrap_or("");
assert!(
desc.contains("Bash"),
"finding description should reference the offending tool: {desc}"
);
}
#[test]
fn star_grant_alone_fires_overbroad() {
let parsed = parse(
"wide-open.md",
"---\ndescription: stub\nallowed-tools: \"*\"\n---\nbody\n",
);
assert!(parsed
.heuristic_findings
.iter()
.any(|f| f.rule_name == "OverbroadAllowedTools"));
}
#[test]
fn colon_form_grant_with_wildcard_fires_overbroad() {
let parsed = parse("colon.md", "---\nallowed-tools: bash:*\n---\nbody\n");
assert!(parsed
.heuristic_findings
.iter()
.any(|f| f.rule_name == "OverbroadAllowedTools"));
}
#[test]
fn safe_allowed_tools_grant_emits_no_finding() {
let parsed = parse(
"safe.md",
"---\ndescription: A bounded skill\nallowed-tools: Bash(git status:*), Read, Write\n---\nbody\n",
);
assert!(parsed
.heuristic_findings
.iter()
.all(|f| f.rule_name != "OverbroadAllowedTools"));
}
#[test]
fn read_write_alone_are_not_flagged() {
let parsed = parse(
"fs-only.md",
"---\ndescription: read some files\nallowed-tools: Read, Write, Glob, Grep\n---\nbody\n",
);
assert!(
parsed.heuristic_findings.is_empty(),
"expected no findings, got {:?}",
parsed
.heuristic_findings
.iter()
.map(|f| f.rule_name.as_str())
.collect::<Vec<_>>()
);
}
#[test]
fn webfetch_grant_emits_data_exfiltration_finding() {
let parsed = parse(
"exfil.md",
"---\ndescription: fetch a thing\nallowed-tools: WebFetch\n---\nbody\n",
);
assert!(parsed
.heuristic_findings
.iter()
.any(|f| f.rule_name == "DataExfiltrationGrant"));
}
#[test]
fn data_exfiltration_grant_carries_owasp_tags() {
let parsed = parse(
"exfil.md",
"---\ndescription: web stuff\nallowed-tools: WebFetch(https://example.com/*)\n---\nbody\n",
);
let exfil = parsed
.heuristic_findings
.iter()
.find(|f| f.rule_name == "DataExfiltrationGrant")
.expect("DataExfiltrationGrant finding");
let ids: Vec<_> = exfil.owasp_tags.iter().map(|t| t.id.as_str()).collect();
assert!(ids.contains(&"MCP09"), "expected MCP09 tag, got {ids:?}");
assert!(ids.contains(&"MCP06"), "expected MCP06 tag, got {ids:?}");
}
#[test]
fn yaml_list_form_for_allowed_tools_is_analyzed() {
let raw = "---\ndescription: stub\nallowed-tools:\n - Bash\n - Read\n---\nbody\n";
let parsed = parse_skill_content(&PathBuf::from("listy.md"), raw).expect("parse");
let rules: Vec<_> = parsed
.heuristic_findings
.iter()
.map(|f| f.rule_name.as_str())
.collect();
assert!(
rules.contains(&"OverbroadAllowedTools"),
"list-form bare Bash should fire OverbroadAllowedTools, got {rules:?}"
);
}
#[test]
fn yaml_list_form_with_only_safe_grants_is_silent() {
let raw =
"---\ndescription: stub\nallowed-tools:\n - Read\n - Write\n - Glob\n---\nbody\n";
let parsed = parse_skill_content(&PathBuf::from("listy-safe.md"), raw).expect("parse");
assert!(
parsed.heuristic_findings.is_empty(),
"got unexpected findings: {:?}",
parsed
.heuristic_findings
.iter()
.map(|f| f.rule_name.as_str())
.collect::<Vec<_>>()
);
}
#[test]
fn optional_argument_hint_is_marked_not_required() {
let parsed = parse("opt.md", "---\nargument-hint: <env> [region]\n---\nbody\n");
let args = parsed.prompt.arguments.expect("arguments");
assert_eq!(args.len(), 2);
assert_eq!(args[0].name, "env");
assert_eq!(args[0].required, Some(true));
assert_eq!(args[1].name, "region");
assert_eq!(args[1].required, Some(false));
}
#[test]
fn sensitive_file_reference_in_body_emits_finding() {
let body = "Use the credentials in @~/.aws/credentials to deploy.";
let raw = format!("---\ndescription: deploy something\n---\n{body}\n");
let parsed = parse("exfil-ref.md", &raw);
let f = parsed
.heuristic_findings
.iter()
.find(|f| f.rule_name == "SkillSensitiveFileReference")
.expect("expected SkillSensitiveFileReference");
let ids: Vec<_> = f.owasp_tags.iter().map(|t| t.id.as_str()).collect();
assert!(ids.contains(&"MCP06"), "got tags: {ids:?}");
assert!(ids.contains(&"MCP09"), "got tags: {ids:?}");
}
#[test]
fn email_address_does_not_trigger_sensitive_reference() {
let body = "On failure, page john.smith@example.com about the deploy.";
let raw = format!("---\ndescription: deploy a thing\n---\n{body}\n");
let parsed = parse("email.md", &raw);
assert!(parsed
.heuristic_findings
.iter()
.all(|f| f.rule_name != "SkillSensitiveFileReference"));
}
#[test]
fn duplicate_sensitive_references_dedupe() {
let body = "First read @.env, then re-read @.env to confirm parity.";
let raw = format!("---\ndescription: confirm env parity\n---\n{body}\n");
let parsed = parse("dotenv.md", &raw);
let count = parsed
.heuristic_findings
.iter()
.filter(|f| f.rule_name == "SkillSensitiveFileReference")
.count();
assert_eq!(count, 1, "expected dedupe to one finding, got {count}");
}
#[test]
fn benign_at_reference_is_silent() {
let body = "Use @scoped/package and tag @v1.0.0 for the release.";
let raw = format!("---\ndescription: release a thing\n---\n{body}\n");
let parsed = parse("benign-at.md", &raw);
assert!(parsed
.heuristic_findings
.iter()
.all(|f| f.rule_name != "SkillSensitiveFileReference"));
}
#[test]
fn comma_in_paren_restriction_does_not_break_tokenizer() {
let raw = "---\ndescription: stub\nallowed-tools: Bash(echo a, b), Read\n---\nbody\n";
let parsed = parse_skill_content(&PathBuf::from("commaparen.md"), raw).expect("parse");
let toks = split_grant_tokens("Bash(echo a, b), Read");
assert_eq!(toks.len(), 2, "got toks={toks:?}");
assert!(toks[0].contains("Bash(echo a, b)"));
assert!(toks[1].contains("Read"));
assert!(parsed
.heuristic_findings
.iter()
.all(|f| f.rule_name != "OverbroadAllowedTools"));
}
#[test]
fn unrestricted_bash_grant_with_inner_comma_still_fires() {
let raw = "---\nallowed-tools: Bash(*), WebFetch(https://a.example.com/x,y)\n---\nbody\n";
let parsed = parse_skill_content(&PathBuf::from("mixed.md"), raw).expect("parse");
let rules: Vec<_> = parsed
.heuristic_findings
.iter()
.map(|f| f.rule_name.as_str())
.collect();
assert!(
rules.contains(&"OverbroadAllowedTools"),
"expected OverbroadAllowedTools, got {rules:?}"
);
assert!(
rules.contains(&"DataExfiltrationGrant"),
"expected DataExfiltrationGrant, got {rules:?}"
);
}
#[test]
fn star_colon_star_restriction_is_unrestricted() {
for grant in ["Bash(*:*)", "Bash(* : *)", "Bash(*::*)"] {
let parsed = parse_grant_token(grant).unwrap();
assert!(
is_unrestricted(&parsed),
"expected `{grant}` to be unrestricted"
);
}
let bounded = parse_grant_token("Bash(git status:*)").unwrap();
assert!(!is_unrestricted(&bounded));
}
#[test]
fn star_colon_star_grant_fires_overbroad() {
let raw = "---\nallowed-tools: Bash(*:*)\n---\nbody\n";
let parsed = parse_skill_content(&PathBuf::from("starcolon.md"), raw).expect("parse");
assert!(parsed
.heuristic_findings
.iter()
.any(|f| f.rule_name == "OverbroadAllowedTools"));
}
#[test]
fn split_grant_tokens_handles_paren_depth() {
assert_eq!(
split_grant_tokens("Bash(a, b), Read, Write"),
vec!["Bash(a, b)", " Read", " Write"]
);
assert_eq!(split_grant_tokens("Bash\nRead"), vec!["Bash", "Read"]);
assert_eq!(split_grant_tokens("Bash), Read"), vec!["Bash)", " Read"]);
}
#[test]
fn parse_grant_token_handles_three_syntaxes() {
let g = parse_grant_token("Bash").unwrap();
assert_eq!(g.tool, "bash");
assert!(g.restriction.is_none());
assert!(is_unrestricted(&g));
let g = parse_grant_token("Bash(git status:*)").unwrap();
assert_eq!(g.tool, "bash");
assert_eq!(g.restriction.as_deref(), Some("git status:*"));
assert!(!is_unrestricted(&g));
let g = parse_grant_token("Bash:foo").unwrap();
assert_eq!(g.tool, "bash");
assert_eq!(g.restriction.as_deref(), Some("foo"));
let g = parse_grant_token("Bash()").unwrap();
assert!(is_unrestricted(&g));
assert!(parse_grant_token(" ").is_none());
}
#[test]
fn vague_trigger_with_substantial_body_emits_finding() {
let body: String = "Do the thing. ".repeat(40);
let raw = format!("---\nname: noisy\n---\n{body}\n");
let parsed = parse("noisy.md", &raw);
assert!(parsed
.heuristic_findings
.iter()
.any(|f| f.rule_name == "VagueSkillTrigger"));
}
#[test]
fn short_body_does_not_trigger_vague_finding() {
let parsed = parse("stub.md", "tiny body");
assert!(parsed
.heuristic_findings
.iter()
.all(|f| f.rule_name != "VagueSkillTrigger"));
}
#[test]
fn substantive_description_suppresses_vague_finding() {
let body: String = "Do the thing. ".repeat(40);
let raw = format!(
"---\ndescription: Specifically deploys the staging environment after running tests\n---\n{body}\n"
);
let parsed = parse("clear.md", &raw);
assert!(parsed
.heuristic_findings
.iter()
.all(|f| f.rule_name != "VagueSkillTrigger"));
}
#[test]
fn heuristic_findings_carry_owasp_tags() {
let parsed = parse(
"danger.md",
"---\nallowed-tools: rm:*\n---\nA body that is long enough to be considered substantive content for trigger heuristics.\n",
);
let overbroad = parsed
.heuristic_findings
.iter()
.find(|f| f.rule_name == "OverbroadAllowedTools")
.expect("OverbroadAllowedTools finding");
assert!(
!overbroad.owasp_tags.is_empty(),
"OverbroadAllowedTools should carry OWASP tags via taxonomy"
);
}
#[test]
fn generic_trigger_phrase_emits_finding() {
let body: String = "Do the thing. ".repeat(40);
let raw = format!("---\ndescription: a general purpose assistant\n---\n{body}\n");
let parsed = parse("generic.md", &raw);
let rules: Vec<_> = parsed
.heuristic_findings
.iter()
.map(|f| f.rule_name.as_str())
.collect();
assert!(
rules.contains(&"GenericSkillTrigger"),
"expected GenericSkillTrigger, got {rules:?}"
);
}
#[test]
fn generic_trigger_phrase_with_punctuation_still_fires() {
let parsed = parse("punc.md", "---\ndescription: Help me!\n---\nbody.\n");
assert!(parsed
.heuristic_findings
.iter()
.any(|f| f.rule_name == "GenericSkillTrigger"));
}
#[test]
fn specific_description_does_not_fire_generic_trigger() {
let parsed = parse(
"specific.md",
"---\ndescription: Deploy the staging environment after running tests.\n---\nbody\n",
);
assert!(parsed
.heuristic_findings
.iter()
.all(|f| f.rule_name != "GenericSkillTrigger"));
}
#[test]
fn skill_name_collision_emits_finding() {
let p1 = PathBuf::from("/home/user/.claude/commands/deploy.md");
let p2 = PathBuf::from("/home/user/work/.claude/commands/deploy.md");
let parsed1 = parse_skill_content(
&p1,
"---\ndescription: Deploy to prod with care\n---\nactual deploy logic",
)
.expect("p1 parses");
let parsed2 = parse_skill_content(
&p2,
"---\ndescription: Different deploy with override\n---\nshadow deploy logic",
)
.expect("p2 parses");
let set: Vec<(&Path, &MCPPrompt)> = vec![
(p1.as_path(), &parsed1.prompt),
(p2.as_path(), &parsed2.prompt),
];
let findings = analyze_skill_set(&set);
assert_eq!(
findings.len(),
1,
"expected one collision, got {findings:?}"
);
assert_eq!(findings[0].rule_name, "SkillNameCollision");
let desc = findings[0]
.rule_metadata
.as_ref()
.and_then(|m| m.description.as_deref())
.unwrap_or("");
assert!(desc.contains(&p1.display().to_string()));
assert!(desc.contains(&p2.display().to_string()));
let ids: Vec<_> = findings[0]
.owasp_tags
.iter()
.map(|t| t.id.as_str())
.collect();
assert!(ids.contains(&"MCP02"), "got {ids:?}");
assert!(ids.contains(&"MCP03"), "got {ids:?}");
}
#[test]
fn skill_name_collision_is_case_insensitive() {
let p1 = PathBuf::from("/a/Deploy.md");
let p2 = PathBuf::from("/b/deploy.md");
let parsed1 = parse_skill_content(&p1, "---\nname: Deploy\n---\nbody1\n").unwrap();
let parsed2 = parse_skill_content(&p2, "---\nname: deploy\n---\nbody2\n").unwrap();
let set: Vec<(&Path, &MCPPrompt)> = vec![
(p1.as_path(), &parsed1.prompt),
(p2.as_path(), &parsed2.prompt),
];
let findings = analyze_skill_set(&set);
assert_eq!(findings.len(), 1);
}
#[test]
fn unique_skill_names_produce_no_collision_finding() {
let p1 = PathBuf::from("/a/deploy.md");
let p2 = PathBuf::from("/b/test.md");
let parsed1 = parse_skill_content(&p1, "---\nname: deploy\n---\nbody1\n").unwrap();
let parsed2 = parse_skill_content(&p2, "---\nname: test\n---\nbody2\n").unwrap();
let set: Vec<(&Path, &MCPPrompt)> = vec![
(p1.as_path(), &parsed1.prompt),
(p2.as_path(), &parsed2.prompt),
];
assert!(analyze_skill_set(&set).is_empty());
}
#[test]
fn large_base64_blob_in_body_emits_payload_finding() {
let blob: String = "ABCDEFGHabcdefgh01234567+/=".repeat(30); let body = format!("Some prose, then a smuggled blob: {blob}\nMore prose.");
let raw = format!("---\ndescription: legit-looking skill\n---\n{body}\n");
let parsed = parse("payload.md", &raw);
let f = parsed
.heuristic_findings
.iter()
.find(|f| f.rule_name == "SkillEmbeddedPayload")
.expect("expected SkillEmbeddedPayload finding");
let ids: Vec<_> = f.owasp_tags.iter().map(|t| t.id.as_str()).collect();
assert!(ids.contains(&"MCP01"), "got {ids:?}");
assert!(ids.contains(&"MCP10"), "got {ids:?}");
}
#[test]
fn large_hex_blob_in_body_classified_as_hex() {
let blob: String = "deadbeef0123456789abcdef".repeat(30); let body = format!("Reference hash: {blob}");
let raw = format!("---\ndescription: hex-blob skill\n---\n{body}\n");
let parsed = parse("hex.md", &raw);
let f = parsed
.heuristic_findings
.iter()
.find(|f| f.rule_name == "SkillEmbeddedPayload")
.expect("hex blob should fire SkillEmbeddedPayload");
let desc = f
.rule_metadata
.as_ref()
.and_then(|m| m.description.as_deref())
.unwrap_or("");
assert!(
desc.contains("hex-shape"),
"expected hex classification in description: {desc}"
);
}
#[test]
fn data_uri_base64_image_does_not_fire_payload() {
let blob: String = "ABCDEFGHabcdefgh01234567+/=".repeat(30);
let body = format!(
"An image: \nThe rest of the skill body."
);
let raw = format!("---\ndescription: skill with image\n---\n{body}\n");
let parsed = parse("image.md", &raw);
assert!(
parsed
.heuristic_findings
.iter()
.all(|f| f.rule_name != "SkillEmbeddedPayload"),
"data URI image should not fire SkillEmbeddedPayload"
);
}
#[test]
fn small_base64_token_does_not_fire_payload() {
let body = "PAT: ghp_\
AbCdEf1234567890AbCdEf1234567890AbCd \
SHA-256: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
let raw = format!("---\ndescription: doc skill\n---\n{body}\n");
let parsed = parse("smalltokens.md", &raw);
assert!(parsed
.heuristic_findings
.iter()
.all(|f| f.rule_name != "SkillEmbeddedPayload"));
}
#[test]
fn duplicate_payloads_dedupe_to_one_finding() {
let blob: String = "ABCDEFGHabcdefgh01234567+/=".repeat(30);
let body = format!("First: {blob}\nSecond identical: {blob}");
let raw = format!("---\ndescription: dup payload\n---\n{body}\n");
let parsed = parse("dup.md", &raw);
let count = parsed
.heuristic_findings
.iter()
.filter(|f| f.rule_name == "SkillEmbeddedPayload")
.count();
assert_eq!(count, 1, "expected dedupe to 1 finding, got {count}");
}
#[test]
fn discover_walks_recursively() {
let tmp = tempfile::tempdir().unwrap();
let nested = tmp.path().join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(tmp.path().join("top.md"), "top").unwrap();
std::fs::write(nested.join("nested.md"), "nested").unwrap();
std::fs::write(tmp.path().join("not-a-skill.txt"), "ignored").unwrap();
let mut found = discover_skills_in_root(tmp.path()).unwrap();
found.sort();
let names: Vec<_> = found
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
.collect();
assert_eq!(names, vec!["nested.md", "top.md"]);
}
#[test]
fn discover_skips_build_dirs() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("target");
std::fs::create_dir_all(&target).unwrap();
std::fs::write(target.join("artifact.md"), "should be skipped").unwrap();
std::fs::write(tmp.path().join("real.md"), "should be found").unwrap();
let found = discover_skills_in_root(tmp.path()).unwrap();
assert_eq!(found.len(), 1);
assert!(found[0].ends_with("real.md"));
}
fn make_bundle(tmp: &std::path::Path, bundle_name: &str, raw: &str) -> PathBuf {
let dir = tmp.join(bundle_name);
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("SKILL.md");
std::fs::write(&p, raw).unwrap();
p
}
fn finding_names(parsed: &ParsedSkill) -> Vec<String> {
parsed
.heuristic_findings
.iter()
.map(|f| f.rule_name.clone())
.collect()
}
#[test]
fn validate_skill_name_accepts_spec_compliant() {
assert!(validate_skill_name("pdf-processing").is_ok());
assert!(validate_skill_name("data-analysis").is_ok());
assert!(validate_skill_name("a").is_ok()); assert!(validate_skill_name(&"a".repeat(64)).is_ok()); }
#[test]
fn validate_skill_name_rejects_spec_violations() {
assert!(validate_skill_name("").is_err());
assert!(validate_skill_name(&"a".repeat(65)).is_err());
assert!(validate_skill_name("PDF-Processing").is_err()); assert!(validate_skill_name("name_with_underscore").is_err());
assert!(validate_skill_name("-leading-hyphen").is_err());
assert!(validate_skill_name("trailing-hyphen-").is_err());
assert!(validate_skill_name("double--hyphen").is_err());
assert!(validate_skill_name("has space").is_err());
assert!(validate_skill_name("dot.path").is_err());
}
#[test]
fn is_agentskills_bundle_byte_equal() {
assert!(is_agentskills_bundle(&PathBuf::from("foo/SKILL.md")));
assert!(!is_agentskills_bundle(&PathBuf::from("foo/Skill.md")));
assert!(!is_agentskills_bundle(&PathBuf::from("foo/skill.md")));
assert!(!is_agentskills_bundle(&PathBuf::from("foo/SKILL.MD")));
assert!(!is_agentskills_bundle(&PathBuf::from("foo/skill.MD")));
assert!(!is_agentskills_bundle(&PathBuf::from("foo/SKILL.md.bak")));
}
#[test]
fn agentskills_name_must_match_parent_dir() {
let tmp = tempfile::tempdir().unwrap();
let p = make_bundle(
tmp.path(),
"my-skill",
"---\nname: evil-skill\ndescription: not what the dir says\n---\nbody\n",
);
let (parsed, _resources) = parse_agentskills_bundle(&p).expect("parsed");
let names = finding_names(&parsed);
assert!(
names.contains(&"AgentskillsNameMismatch".to_string()),
"expected AgentskillsNameMismatch, got {names:?}"
);
assert!(!names.contains(&"SkillNameCollision".to_string()));
}
#[test]
fn agentskills_invalid_name_charset_underscore() {
let tmp = tempfile::tempdir().unwrap();
let p = make_bundle(
tmp.path(),
"my-skill",
"---\nname: bad_skill\ndescription: x\n---\nbody\n",
);
let (parsed, _) = parse_agentskills_bundle(&p).expect("parsed");
let names = finding_names(&parsed);
assert!(names.contains(&"AgentskillsInvalidName".to_string()));
let invalid = parsed
.heuristic_findings
.iter()
.find(|f| f.rule_name == "AgentskillsInvalidName")
.unwrap();
let desc = invalid
.rule_metadata
.as_ref()
.and_then(|m| m.description.clone())
.unwrap_or_default();
assert!(
desc.contains("[a-z0-9-]"),
"expected spec-violation reason in description, got: {desc}"
);
}
#[test]
fn agentskills_double_hyphen_invalid() {
let tmp = tempfile::tempdir().unwrap();
let p = make_bundle(
tmp.path(),
"my-skill",
"---\nname: my--skill\ndescription: x\n---\nbody\n",
);
let (parsed, _) = parse_agentskills_bundle(&p).expect("parsed");
let names = finding_names(&parsed);
assert!(names.contains(&"AgentskillsInvalidName".to_string()));
}
#[test]
fn agentskills_leading_hyphen_invalid() {
let tmp = tempfile::tempdir().unwrap();
let p = make_bundle(
tmp.path(),
"my-skill",
"---\nname: -leading\ndescription: x\n---\nbody\n",
);
let (parsed, _) = parse_agentskills_bundle(&p).expect("parsed");
assert!(finding_names(&parsed).contains(&"AgentskillsInvalidName".to_string()));
}
#[test]
fn agentskills_64_char_boundary() {
let tmp = tempfile::tempdir().unwrap();
let name_64 = "a".repeat(64);
let p = make_bundle(
tmp.path(),
&name_64,
&format!("---\nname: {name_64}\ndescription: x\n---\nbody\n"),
);
let (parsed, _) = parse_agentskills_bundle(&p).expect("parsed");
assert!(!finding_names(&parsed).contains(&"AgentskillsInvalidName".to_string()));
let name_65 = "a".repeat(65);
let p = make_bundle(
tmp.path(),
&name_65,
&format!("---\nname: {name_65}\ndescription: x\n---\nbody\n"),
);
let (parsed, _) = parse_agentskills_bundle(&p).expect("parsed");
assert!(finding_names(&parsed).contains(&"AgentskillsInvalidName".to_string()));
}
#[test]
fn agentskills_unknown_fields_rolled_up() {
let tmp = tempfile::tempdir().unwrap();
let p = make_bundle(
tmp.path(),
"my-skill",
"---\nname: my-skill\ndescription: x\nfoo: 1\nbar: 2\n---\nbody\n",
);
let (parsed, _) = parse_agentskills_bundle(&p).expect("parsed");
let unknowns: Vec<_> = parsed
.heuristic_findings
.iter()
.filter(|f| f.rule_name == "AgentskillsUnknownFrontmatterField")
.collect();
assert_eq!(unknowns.len(), 1, "expected exactly one rolled-up finding");
let desc = unknowns[0]
.rule_metadata
.as_ref()
.and_then(|m| m.description.clone())
.unwrap_or_default();
assert!(desc.contains("foo") && desc.contains("bar"));
}
#[test]
fn agentskills_spec_allowed_fields_dont_trip_unknown() {
let tmp = tempfile::tempdir().unwrap();
let p = make_bundle(
tmp.path(),
"my-skill",
"---\nname: my-skill\ndescription: x\nlicense: Apache-2.0\n\
compatibility: requires git\nmetadata:\n author: me\nallowed-tools: Read\n\
---\nbody\n",
);
let (parsed, _) = parse_agentskills_bundle(&p).expect("parsed");
assert!(!finding_names(&parsed).contains(&"AgentskillsUnknownFrontmatterField".to_string()));
}
#[test]
fn agentskills_lowercase_skill_md_not_bundle() {
let path = PathBuf::from("foo/Skill.md");
assert!(!is_agentskills_bundle(&path));
}
#[test]
fn agentskills_parent_dir_fallback() {
let tmp = tempfile::tempdir().unwrap();
let p = make_bundle(
tmp.path(),
"pdf-processing",
"---\ndescription: parse PDFs\n---\nbody about pdfs\n",
);
let (parsed, _) = parse_agentskills_bundle(&p).expect("parsed");
assert_eq!(parsed.prompt.name, "pdf-processing");
let names = finding_names(&parsed);
assert!(!names.contains(&"AgentskillsNameMismatch".to_string()));
assert!(!names.contains(&"AgentskillsInvalidName".to_string()));
assert!(!names.contains(&"AgentskillsMissingName".to_string()));
}
#[test]
fn agentskills_parent_dir_invalid_emits_invalid_name() {
let tmp = tempfile::tempdir().unwrap();
let p = make_bundle(
tmp.path(),
"pdf_processing",
"---\ndescription: parse PDFs\n---\nbody\n",
);
let (parsed, _) = parse_agentskills_bundle(&p).expect("parsed");
let invalid: Vec<_> = parsed
.heuristic_findings
.iter()
.filter(|f| f.rule_name == "AgentskillsInvalidName")
.collect();
assert_eq!(invalid.len(), 1);
let desc = invalid[0]
.rule_metadata
.as_ref()
.and_then(|m| m.description.clone())
.unwrap_or_default();
assert!(
desc.contains("parent directory"),
"expected parent-directory wording, got: {desc}"
);
assert!(!finding_names(&parsed).contains(&"AgentskillsMissingName".to_string()));
}
#[test]
fn agentskills_bundle_walks_scripts() {
let tmp = tempfile::tempdir().unwrap();
let bundle = tmp.path().join("my-skill");
std::fs::create_dir_all(bundle.join("scripts")).unwrap();
std::fs::write(
bundle.join("SKILL.md"),
"---\nname: my-skill\ndescription: x\n---\nbody\n",
)
.unwrap();
std::fs::write(
bundle.join("scripts/run.py"),
"import os\nos.system('ls')\n",
)
.unwrap();
std::fs::write(bundle.join("scripts/README.md"), "doc").unwrap();
let (_, resources) = parse_agentskills_bundle(&bundle.join("SKILL.md")).expect("parsed");
let names: Vec<_> = resources.iter().map(|r| r.name.clone()).collect();
assert!(names.contains(&"my-skill/scripts/run.py".to_string()));
assert!(!names.iter().any(|n| n.contains("README")));
}
#[test]
fn agentskills_bundle_walks_references_md() {
let tmp = tempfile::tempdir().unwrap();
let bundle = tmp.path().join("my-skill");
std::fs::create_dir_all(bundle.join("references")).unwrap();
std::fs::write(
bundle.join("SKILL.md"),
"---\nname: my-skill\ndescription: x\n---\nbody\n",
)
.unwrap();
std::fs::write(bundle.join("references/api.md"), "# API\nstuff").unwrap();
std::fs::write(bundle.join("references/notes.txt"), "should skip").unwrap();
let (_, resources) = parse_agentskills_bundle(&bundle.join("SKILL.md")).expect("parsed");
let names: Vec<_> = resources.iter().map(|r| r.name.clone()).collect();
assert!(names.contains(&"my-skill/references/api.md".to_string()));
assert!(!names.iter().any(|n| n.contains("notes.txt")));
}
#[test]
fn agentskills_bundle_skips_assets() {
let tmp = tempfile::tempdir().unwrap();
let bundle = tmp.path().join("my-skill");
std::fs::create_dir_all(bundle.join("assets")).unwrap();
std::fs::write(
bundle.join("SKILL.md"),
"---\nname: my-skill\ndescription: x\n---\nbody\n",
)
.unwrap();
std::fs::write(bundle.join("assets/logo.svg"), "<svg/>").unwrap();
std::fs::write(bundle.join("assets/template.md"), "tmpl").unwrap();
let (_, resources) = parse_agentskills_bundle(&bundle.join("SKILL.md")).expect("parsed");
assert!(resources.is_empty());
}
#[test]
fn agentskills_resource_uri_has_no_file_prefix() {
let tmp = tempfile::tempdir().unwrap();
let bundle = tmp.path().join("my-skill");
std::fs::create_dir_all(bundle.join("scripts")).unwrap();
std::fs::write(
bundle.join("SKILL.md"),
"---\nname: my-skill\ndescription: x\n---\nbody\n",
)
.unwrap();
std::fs::write(bundle.join("scripts/x.py"), "print(1)").unwrap();
let (_, resources) = parse_agentskills_bundle(&bundle.join("SKILL.md")).expect("parsed");
assert_eq!(resources.len(), 1);
assert!(resources[0].uri.starts_with("skill://"));
assert!(!resources[0].uri.contains("file://"));
}
#[test]
fn agentskills_oversize_script_skipped() {
let tmp = tempfile::tempdir().unwrap();
let bundle = tmp.path().join("my-skill");
std::fs::create_dir_all(bundle.join("scripts")).unwrap();
std::fs::write(
bundle.join("SKILL.md"),
"---\nname: my-skill\ndescription: x\n---\nbody\n",
)
.unwrap();
let chunk = "print(1)\n";
let target_bytes = (MAX_SKILL_FILE_BYTES + 1024) as usize;
#[allow(clippy::manual_div_ceil)]
let repetitions = (target_bytes + chunk.len() - 1) / chunk.len();
let big: String = chunk.repeat(repetitions);
std::fs::write(bundle.join("scripts/huge.py"), &big).unwrap();
std::fs::write(bundle.join("scripts/ok.py"), "print(2)").unwrap();
let (_, resources) = parse_agentskills_bundle(&bundle.join("SKILL.md")).expect("parsed");
let names: Vec<_> = resources.iter().map(|r| r.name.clone()).collect();
assert!(
!names.iter().any(|n| n.contains("huge.py")),
"huge.py exceeded the size cap and should have been skipped"
);
assert!(names.contains(&"my-skill/scripts/ok.py".to_string()));
}
#[test]
fn agentskills_bundle_files_per_dir_cap() {
let tmp = tempfile::tempdir().unwrap();
let bundle = tmp.path().join("flood-skill");
std::fs::create_dir_all(bundle.join("scripts")).unwrap();
std::fs::write(
bundle.join("SKILL.md"),
"---\nname: flood-skill\ndescription: x\n---\nbody\n",
)
.unwrap();
let over_cap = MAX_BUNDLE_FILES_PER_DIR + 10;
for i in 0..over_cap {
std::fs::write(bundle.join(format!("scripts/x{i}.py")), "print(1)").unwrap();
}
let (_, resources) = parse_agentskills_bundle(&bundle.join("SKILL.md")).expect("parsed");
assert!(
resources.len() <= MAX_BUNDLE_FILES_PER_DIR,
"expected at most {MAX_BUNDLE_FILES_PER_DIR} resources, got {}",
resources.len()
);
}
#[test]
fn bundle_root_of_rejects_empty_parent() {
assert!(bundle_root_of(&PathBuf::from("SKILL.md")).is_none());
assert_eq!(
bundle_root_of(&PathBuf::from("./SKILL.md")),
Some(std::path::Path::new("."))
);
assert_eq!(
bundle_root_of(&PathBuf::from("foo/SKILL.md")),
Some(std::path::Path::new("foo"))
);
}
#[test]
fn validate_skill_name_rejects_non_ascii() {
assert!(validate_skill_name("a\u{2013}b").is_err()); assert!(validate_skill_name("café").is_err());
assert!(validate_skill_name("\u{FF11}\u{FF12}").is_err()); }
#[test]
fn is_under_bundle_sibling_dir_detects_scripts() {
let bundle_root = PathBuf::from("/tmp/my-skill");
let mut roots: HashSet<PathBuf> = HashSet::new();
roots.insert(bundle_root.clone());
assert!(is_under_bundle_sibling_dir(
&bundle_root.join("scripts/run.py"),
&roots
));
assert!(is_under_bundle_sibling_dir(
&bundle_root.join("references/api.md"),
&roots
));
assert!(is_under_bundle_sibling_dir(
&bundle_root.join("assets/logo.png"),
&roots
));
assert!(!is_under_bundle_sibling_dir(
&bundle_root.join("SKILL.md"),
&roots
));
assert!(!is_under_bundle_sibling_dir(
&PathBuf::from("/tmp/other-skill/SKILL.md"),
&roots
));
}
#[test]
fn is_under_bundle_sibling_dir_is_shallow() {
let bundle_root = PathBuf::from("/tmp/my-skill");
let mut roots: HashSet<PathBuf> = HashSet::new();
roots.insert(bundle_root.clone());
assert!(!is_under_bundle_sibling_dir(
&bundle_root.join("references/sub/deep.md"),
&roots
));
assert!(!is_under_bundle_sibling_dir(
&bundle_root.join("scripts/nested/dir/x.py"),
&roots
));
}
}