use std::fmt;
use crate::summary::slugify;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warn,
}
impl Severity {
fn label(self) -> &'static str {
match self {
Severity::Error => "ERROR",
Severity::Warn => "warn",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Category {
Security,
Optimization,
Practicality,
}
impl Category {
fn label(self) -> &'static str {
match self {
Category::Security => "security",
Category::Optimization => "optimization",
Category::Practicality => "practicality",
}
}
}
#[derive(Debug, Clone)]
pub struct Finding {
pub severity: Severity,
pub category: Category,
pub code: &'static str,
pub message: String,
pub line: Option<usize>,
}
#[derive(Debug, Clone, Default)]
pub struct ValidationReport {
pub findings: Vec<Finding>,
}
impl ValidationReport {
fn push(
&mut self,
severity: Severity,
category: Category,
code: &'static str,
message: impl Into<String>,
line: Option<usize>,
) {
self.findings.push(Finding {
severity,
category,
code,
message: message.into(),
line,
});
}
pub fn is_empty(&self) -> bool {
self.findings.is_empty()
}
pub fn errors(&self) -> usize {
self.findings
.iter()
.filter(|f| f.severity == Severity::Error)
.count()
}
pub fn warnings(&self) -> usize {
self.findings
.iter()
.filter(|f| f.severity == Severity::Warn)
.count()
}
pub fn has_blocking(&self, strict: bool) -> bool {
self.findings
.iter()
.any(|f| f.severity == Severity::Error || (strict && f.severity == Severity::Warn))
}
pub fn blocking_count(&self, strict: bool) -> usize {
self.findings
.iter()
.filter(|f| f.severity == Severity::Error || (strict && f.severity == Severity::Warn))
.count()
}
}
impl fmt::Display for ValidationReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for finding in &self.findings {
let where_ = finding
.line
.map(|l| format!(" (line {l})"))
.unwrap_or_default();
writeln!(
f,
" [{}] {}/{}{} — {}",
finding.severity.label(),
finding.category.label(),
finding.code,
where_,
finding.message
)?;
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct ValidationCtx {
pub draft: bool,
pub strict: bool,
pub home: Option<String>,
}
impl ValidationCtx {
pub fn new(draft: bool, strict: bool) -> Self {
Self {
draft,
strict,
home: crate::paths::home_dir().map(|p| p.display().to_string()),
}
}
}
pub fn validate_skill(md: &str, ctx: &ValidationCtx) -> ValidationReport {
let mut report = ValidationReport::default();
security_findings(md, ctx.home.as_deref(), &mut report);
if !ctx.draft {
practicality_findings(md, &mut report);
}
optimization_findings(md, &mut report);
report
}
fn security_findings(md: &str, home: Option<&str>, report: &mut ValidationReport) {
let mut in_fence = false;
for (idx, line) in md.lines().enumerate() {
let lineno = idx + 1;
if is_fence_delimiter(line) {
in_fence = !in_fence;
continue;
}
if crate::export::contains_secret(line) {
report.push(
Severity::Error,
Category::Security,
"SEC_SECRET",
"a secret-shaped token or key block is present; remove it before installing",
Some(lineno),
);
}
for hit in pii_hits(line, home) {
report.push(
Severity::Error,
Category::Security,
hit.code,
hit.message,
Some(lineno),
);
}
if let Some(message) = injection_hit(line) {
report.push(
Severity::Warn,
Category::Security,
"SEC_INJECTION",
message,
Some(lineno),
);
}
let inline = inline_code_mask(line);
for hit in dangerous_hits(line) {
let in_inline = inline.get(hit.offset).copied().unwrap_or(false);
let documented = in_fence || in_inline;
match (hit.fatal, documented) {
(true, false) => report.push(
Severity::Error,
Category::Security,
"SEC_DANGER_CMD_FATAL",
format!("catastrophic command in prose: {}", hit.message),
Some(lineno),
),
(true, true) => report.push(
Severity::Warn,
Category::Security,
"SEC_DANGER_CMD",
format!("catastrophic command (documented): {}", hit.message),
Some(lineno),
),
(false, false) => report.push(
Severity::Warn,
Category::Security,
"SEC_DANGER_CMD",
format!("dangerous command in prose: {}", hit.message),
Some(lineno),
),
(false, true) => {}
}
}
}
}
fn is_fence_delimiter(line: &str) -> bool {
let t = line.trim_start();
t.starts_with("```") || t.starts_with("~~~")
}
fn inline_code_mask(line: &str) -> Vec<bool> {
let mut mask = vec![false; line.len()];
let mut inside = false;
for (i, c) in line.char_indices() {
if c == '`' {
inside = !inside;
continue;
}
for b in i..i + c.len_utf8() {
if b < mask.len() {
mask[b] = inside;
}
}
}
mask
}
struct PiiHit {
code: &'static str,
message: String,
}
fn pii_hits(line: &str, home: Option<&str>) -> Vec<PiiHit> {
let mut hits = Vec::new();
let mut flagged_path = false;
const ROOTS: &[&str] = &[
"/Users/",
"/home/",
"/private/tmp/",
"/tmp/",
"/var/folders/",
];
for root in ROOTS {
if let Some(pos) = line.find(root) {
let after = &line[pos + root.len()..];
let has_segment = after
.chars()
.next()
.is_some_and(|c| !c.is_whitespace() && c != '/');
if (*root == "/Users/" || *root == "/home/") && !has_segment {
continue;
}
flagged_path = true;
hits.push(PiiHit {
code: "SEC_PII_PATH",
message: format!(
"personal or session path under `{root}`; generalize it to `~/…` or drop it"
),
});
}
}
if !flagged_path
&& let Some(home) = home.filter(|h| h.starts_with('/') && h.len() > 1)
&& line.contains(home)
{
hits.push(PiiHit {
code: "SEC_PII_PATH",
message: "personal path under your home directory; generalize it to `~/…`".to_string(),
});
}
if line.split(is_token_delimiter).any(is_email_token) {
hits.push(PiiHit {
code: "SEC_PII_EMAIL",
message: "an email address is present; remove personal contact data".to_string(),
});
}
hits
}
fn is_token_delimiter(c: char) -> bool {
c.is_whitespace()
|| matches!(
c,
'"' | '\'' | '`' | '(' | ')' | ',' | ';' | '<' | '>' | '='
)
}
fn is_email_token(token: &str) -> bool {
let token = token.trim_matches(|c: char| !c.is_ascii_alphanumeric());
let Some(at) = token.find('@') else {
return false;
};
let (local, domain) = (&token[..at], &token[at + 1..]);
if local.is_empty()
|| !local
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '%' | '+' | '-'))
{
return false;
}
let Some(dot) = domain.rfind('.') else {
return false;
};
let (host, tld) = (&domain[..dot], &domain[dot + 1..]);
!host.is_empty()
&& host
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-'))
&& tld.len() >= 2
&& tld.chars().all(|c| c.is_ascii_alphabetic())
}
struct DangerHit {
offset: usize,
fatal: bool,
message: String,
}
fn dangerous_hits(line: &str) -> Vec<DangerHit> {
let mut hits = Vec::new();
for flag in ["rm -rf", "rm -fr", "rm -r -f", "rm -f -r"] {
let mut from = 0;
while let Some(rel) = line[from..].find(flag) {
let at = from + rel;
let target = line[at + flag.len()..].trim_start();
let fatal = is_catastrophic_target(target);
hits.push(DangerHit {
offset: at,
fatal,
message: if fatal {
"recursive force-delete of a filesystem or home root".to_string()
} else {
"recursive force-delete (`rm -rf`)".to_string()
},
});
from = at + flag.len();
}
}
if let Some(at) = line.find(":(){").or_else(|| line.find(":|:&")) {
hits.push(DangerHit {
offset: at,
fatal: true,
message: "fork bomb".to_string(),
});
}
if let Some(at) = line.find("mkfs") {
hits.push(DangerHit {
offset: at,
fatal: true,
message: "filesystem creation (`mkfs`)".to_string(),
});
}
if line.contains("dd ")
&& let Some(at) = ["of=/dev/sd", "of=/dev/disk", "of=/dev/nvme", "of=/dev/hd"]
.iter()
.find_map(|p| line.find(p))
{
hits.push(DangerHit {
offset: at,
fatal: true,
message: "raw write to a block device (`dd of=/dev/…`)".to_string(),
});
}
push_warn(
&mut hits,
line,
"push --force",
"force-push (`git push --force`)",
);
push_warn(&mut hits, line, "push -f", "force-push (`git push -f`)");
push_warn(&mut hits, line, "sudo ", "elevated privileges (`sudo`)");
push_warn(
&mut hits,
line,
"chmod 777",
"world-writable permissions (`chmod 777`)",
);
if (line.contains("curl") || line.contains("wget"))
&& let Some(at) = ["| sh", "|sh", "| bash", "|bash"]
.iter()
.find_map(|p| line.find(p))
{
hits.push(DangerHit {
offset: at,
fatal: false,
message: "piping a download straight into a shell (`curl | sh`)".to_string(),
});
}
hits
}
fn push_warn(hits: &mut Vec<DangerHit>, line: &str, needle: &str, message: &str) {
if let Some(at) = line.find(needle) {
hits.push(DangerHit {
offset: at,
fatal: false,
message: message.to_string(),
});
}
}
fn is_catastrophic_target(target: &str) -> bool {
let first = target.split(is_token_delimiter).next().unwrap_or("");
if first.starts_with('/') && first.trim_start_matches(['/', '*']).is_empty() {
return true;
}
matches!(first, "~" | "~/" | "$HOME" | "$HOME/")
|| (first.starts_with("~/") && first[2..].chars().all(|c| c == '*'))
|| (first.starts_with("$HOME/") && first["$HOME/".len()..].chars().all(|c| c == '*'))
}
fn injection_hit(line: &str) -> Option<String> {
let lower = line.to_ascii_lowercase();
const PHRASES: &[&str] = &[
"ignore previous instructions",
"ignore all previous instructions",
"disregard previous instructions",
"you are now",
"<|im_start|>",
"<|im_end|>",
];
for phrase in PHRASES {
if lower.contains(phrase) {
return Some(format!("possible prompt-injection phrase: \"{phrase}\""));
}
}
if line.chars().any(is_suspicious_invisible) {
return Some("invisible bidi/zero-width control character".to_string());
}
None
}
fn is_suspicious_invisible(c: char) -> bool {
matches!(c,
'\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' )
}
fn practicality_findings(md: &str, report: &mut ValidationReport) {
if let Err(err) = crate::distill::validate_skill_md(md) {
report.push(
Severity::Error,
Category::Practicality,
"PRAC_STRUCTURE",
format!("{err:#}"),
None,
);
}
for section in empty_required_sections(md) {
report.push(
Severity::Error,
Category::Practicality,
"PRAC_EMPTY_SECTION",
format!("required section `## {section}` is empty"),
None,
);
}
}
fn empty_required_sections(md: &str) -> Vec<&'static str> {
const REQUIRED: &[&str] = &[
"When to use",
"Steps",
"Verification",
"Goal",
"Procedure",
"Success criteria",
];
let mut empty = Vec::new();
for §ion in REQUIRED {
if let Some(body) = section_body(md, section)
&& body.trim().is_empty()
{
empty.push(section);
}
}
empty
}
fn section_body<'a>(md: &'a str, section: &str) -> Option<&'a str> {
let heading = format!("## {section}");
let mut start_byte = None;
let mut offset = 0;
for line in md.lines() {
if line.trim().eq_ignore_ascii_case(&heading) {
start_byte = Some(offset + line.len());
break;
}
offset += line.len() + 1;
}
let rest = &md[start_byte?.min(md.len())..];
let end = rest
.match_indices("\n## ")
.next()
.map(|(i, _)| i)
.unwrap_or(rest.len());
Some(&rest[..end])
}
fn optimization_findings(md: &str, report: &mut ValidationReport) {
if is_tautological_description(md) {
report.push(
Severity::Warn,
Category::Optimization,
"OPT_TAUTOLOGICAL_DESC",
"the description's \"when to use\" just restates the task name; say what the task does and when to reach for it",
None,
);
}
for (lineno, summary) in noise_step_lines(md) {
report.push(
Severity::Warn,
Category::Optimization,
"OPT_NOISE_STEP",
format!("step looks like recording noise, not part of the task: {summary}"),
Some(lineno),
);
}
const MAX_CHARS: usize = 16_000;
if md.len() > MAX_CHARS {
report.push(
Severity::Warn,
Category::Optimization,
"OPT_LONG",
format!(
"skill is {} chars (> {MAX_CHARS}); tighten it so an agent can hold it",
md.len()
),
None,
);
}
}
pub(crate) fn is_tautological_description(md: &str) -> bool {
let Some(desc) = frontmatter_value(md, "description") else {
return false;
};
let desc = desc.to_ascii_lowercase();
let Some(marker_at) = desc.find("you need to ") else {
return false;
};
let clause = &desc[marker_at + "you need to ".len()..];
let clause = clause.split('.').next().unwrap_or(clause);
let clause_slug = slugify(clause);
if clause_slug.is_empty() {
return false;
}
let name_slug = frontmatter_value(md, "name")
.map(|n| slugify(n.trim_start_matches("galdr-")))
.unwrap_or_default();
!name_slug.is_empty() && (clause_slug == name_slug || name_slug.contains(&clause_slug))
}
pub(crate) fn noise_step_count(md: &str) -> usize {
noise_step_lines(md).len()
}
fn noise_step_lines(md: &str) -> Vec<(usize, String)> {
let mut out = Vec::new();
for (idx, line) in md.lines().enumerate() {
if let Some((tool, summary)) = parse_step_line(line)
&& is_noise_step(tool, summary)
{
out.push((idx + 1, summary.to_string()));
}
}
out
}
fn parse_step_line(line: &str) -> Option<(&str, &str)> {
let line = line.trim_start();
let rest = line.split_once(". **")?.1;
let (tool, after) = rest.split_once("** — ")?;
Some((tool, after))
}
pub(crate) fn is_noise_step(tool: &str, summary: &str) -> bool {
let s = summary.trim();
if s.contains("galdr rec start")
|| s.contains("galdr rec stop")
|| s.contains("galdr rec status")
{
return true;
}
if is_bare_sleep(s) || (s.starts_with("while ") && s.contains("sleep")) {
return true;
}
if s == "screenshot" {
return true;
}
if (tool == "Read" && is_temp_path(s)) || (tool == "Bash" && is_temp_cat(s)) {
return true;
}
false
}
fn is_bare_sleep(s: &str) -> bool {
s.strip_prefix("sleep ")
.map(|rest| {
let head = rest.split_whitespace().next().unwrap_or("");
!head.is_empty() && head.chars().all(|c| c.is_ascii_digit() || c == '.')
})
.unwrap_or(false)
}
fn is_temp_cat(s: &str) -> bool {
s.strip_prefix("cat ")
.map(|rest| rest.split_whitespace().next().is_some_and(is_temp_path))
.unwrap_or(false)
}
pub(crate) fn is_temp_path(s: &str) -> bool {
let s = s.trim_matches(|c| matches!(c, '`' | '"' | '\''));
s.starts_with("/tmp/")
|| s.starts_with("/private/tmp/")
|| s.starts_with("/var/folders/")
|| s.contains("/scratchpad/")
}
pub(crate) fn generalize_session_text(text: &str, home: Option<&str>) -> String {
let mut out = text.to_string();
if let Some(home) = home.filter(|h| !h.is_empty() && h.starts_with('/')) {
out = replace_path_prefix(&out, home, "~");
}
out = replace_path_prefix(&out, "/Users/", "~/USER/");
out = replace_path_prefix(&out, "/home/", "~/USER/");
out = collapse_user_segment(&out);
out = replace_temp_paths(&out);
out = replace_emails(&out);
out
}
fn replace_path_prefix(text: &str, prefix: &str, with: &str) -> String {
if !text.contains(prefix) {
return text.to_string();
}
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(at) = rest.find(prefix) {
out.push_str(&rest[..at]);
out.push_str(with);
rest = &rest[at + prefix.len()..];
}
out.push_str(rest);
out
}
fn collapse_user_segment(text: &str) -> String {
const MARK: &str = "~/USER/";
if !text.contains(MARK) {
return text.to_string();
}
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(at) = rest.find(MARK) {
out.push_str(&rest[..at]);
out.push('~');
let after = &rest[at + MARK.len()..];
let seg_end = after
.find(|c: char| c == '/' || is_token_delimiter(c))
.unwrap_or(after.len());
let tail = &after[seg_end..];
rest = tail;
}
out.push_str(rest);
out
}
fn replace_temp_paths(text: &str) -> String {
let mut out = text.to_string();
for root in ["/private/tmp/", "/tmp/", "/var/folders/"] {
out = replace_temp_root(&out, root);
}
out
}
fn replace_temp_root(text: &str, root: &str) -> String {
if !text.contains(root) {
return text.to_string();
}
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(at) = rest.find(root) {
out.push_str(&rest[..at]);
out.push_str("<temp path>");
let after = &rest[at + root.len()..];
let end = after.find(is_token_delimiter).unwrap_or(after.len());
rest = &after[end..];
}
out.push_str(rest);
out
}
fn replace_emails(text: &str) -> String {
if !text.contains('@') {
return text.to_string();
}
text.split_inclusive(is_token_delimiter)
.map(|chunk| {
let (token, delim) = split_trailing_delim(chunk);
if is_email_token(token) {
format!("<email>{delim}")
} else {
chunk.to_string()
}
})
.collect()
}
fn split_trailing_delim(chunk: &str) -> (&str, &str) {
match chunk.char_indices().last() {
Some((i, c)) if is_token_delimiter(c) => (&chunk[..i], &chunk[i..]),
_ => (chunk, ""),
}
}
fn frontmatter_value(md: &str, key: &str) -> Option<String> {
let body = md.trim_start_matches(['\u{feff}', ' ', '\t', '\n', '\r']);
let after_open = body.strip_prefix("---")?;
let opener_end = after_open.find('\n').map_or(after_open.len(), |i| i + 1);
if !after_open[..opener_end].trim().is_empty() {
return None;
}
let inner = &after_open[opener_end..];
for line in inner.lines() {
if line.trim() == "---" {
break;
}
if let Some(rest) = line.trim_start().strip_prefix(key)
&& let Some(value) = rest.strip_prefix(':')
{
return Some(value.trim().trim_matches('"').to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx() -> ValidationCtx {
ValidationCtx {
draft: false,
strict: false,
home: None,
}
}
const COMPLETE: &str = "---\nname: galdr-demo\ndescription: \"Do a thing and check it.\"\n---\n\n## When to use\n\nWhen you must do the thing.\n\n## Steps\n\n1. **Bash** — git status\n\n## Verification\n\nConfirm it ran.\n";
#[test]
fn a_clean_skill_passes() {
let report = validate_skill(COMPLETE, &ctx());
assert!(!report.has_blocking(false), "{report}");
assert!(!report.has_blocking(true), "{report}");
}
#[test]
fn blocks_a_personal_path() {
let md = format!("{COMPLETE}\n- cwd: `/Users/alice/Projects/x`\n");
let report = validate_skill(&md, &ctx());
assert!(report.has_blocking(false));
assert!(report.findings.iter().any(|f| f.code == "SEC_PII_PATH"));
}
#[test]
fn home_relative_path_is_clean() {
let md = format!("{COMPLETE}\n- raw under `~/.galdr/spans/x.jsonl`\n");
let report = validate_skill(&md, &ctx());
assert!(
!report.findings.iter().any(|f| f.code == "SEC_PII_PATH"),
"{report}"
);
}
#[test]
fn blocks_a_secret() {
let md = format!("{COMPLETE}\n1. **Bash** — export TOKEN=ghp_ABCDEF0123456789abcdef\n");
let report = validate_skill(&md, &ctx());
assert!(report.has_blocking(false));
assert!(report.findings.iter().any(|f| f.code == "SEC_SECRET"));
}
#[test]
fn allows_a_documented_rm_in_a_fence() {
let md = format!("{COMPLETE}\n```sh\nrm -rf ./node_modules\nrm -rf /\n```\n");
let report = validate_skill(&md, &ctx());
assert!(
!report
.findings
.iter()
.any(|f| f.code == "SEC_DANGER_CMD_FATAL"),
"{report}"
);
assert!(!report.has_blocking(false), "{report}");
}
#[test]
fn force_push_in_prose_only_warns() {
let md =
format!("{COMPLETE}\n\nThen run git push --force to overwrite the remote branch.\n");
let report = validate_skill(&md, &ctx());
assert!(
!report.has_blocking(false),
"documented force-push installs"
);
assert!(report.has_blocking(true), "strict blocks it");
assert!(
report
.findings
.iter()
.any(|f| f.code == "SEC_DANGER_CMD" && f.severity == Severity::Warn)
);
}
#[test]
fn gate_allows_release_skill() {
let md = "---\nname: galdr-release\ndescription: \"Harden a repo's release automation by porting a working pattern from a reference repo.\"\n---\n\n## When to use\n\nWhen the release PR never auto-tags and you keep cutting tags by hand.\n\n## Steps\n\n1. **Bash** — gh pr list --label 'autorelease: pending' --state open\n2. If the release branch diverged, recover it with git push --force.\n\n## Verification\n\nThe bot tags the next release; no manual `gh release create` was needed.\n";
let report = validate_skill(md, &ctx());
assert!(
!report.has_blocking(false),
"a documented force-push installs: {report}"
);
assert!(report.has_blocking(true), "strict refuses it: {report}");
assert!(
report
.findings
.iter()
.all(|f| f.severity != Severity::Error),
"nothing here is a hard error: {report}"
);
}
#[test]
fn catastrophic_rm_in_prose_blocks() {
let md = format!("{COMPLETE}\n\nClean everything with rm -rf / now.\n");
let report = validate_skill(&md, &ctx());
assert!(report.has_blocking(false));
assert!(
report
.findings
.iter()
.any(|f| f.code == "SEC_DANGER_CMD_FATAL")
);
}
#[test]
fn flags_a_tautological_description() {
let md = "---\nname: galdr-cu-demo-calc\ndescription: \"Reproduce the task. Use this when you need to cu-demo-calc.\"\n---\n\n## When to use\n\nx\n\n## Steps\n\n1. **Bash** — echo hi\n\n## Verification\n\ny\n";
let report = validate_skill(md, &ctx());
assert!(
report
.findings
.iter()
.any(|f| f.code == "OPT_TAUTOLOGICAL_DESC")
);
assert!(!report.has_blocking(false));
assert!(report.has_blocking(true));
}
#[test]
fn draft_keeps_security_drops_practicality() {
let draft_md = "---\nname: galdr-x\ndescription: \"x\"\n---\n\n## Goal\n\n<!-- TODO(agent): fill -->\n";
let draft_ctx = ValidationCtx {
draft: true,
strict: false,
home: None,
};
let report = validate_skill(draft_md, &draft_ctx);
assert!(
!report.has_blocking(false),
"a draft marker must not block a draft: {report}"
);
let leaky = format!("{draft_md}\n- cwd: `/Users/bob/x`\n");
let report = validate_skill(&leaky, &draft_ctx);
assert!(
report.has_blocking(false),
"security is a wall even for drafts: {report}"
);
assert!(
report
.findings
.iter()
.all(|f| f.category != Category::Practicality)
);
}
#[test]
fn non_draft_blocks_a_leftover_marker() {
let md = format!("{COMPLETE}\n<!-- TODO(agent): finish -->\n");
let report = validate_skill(&md, &ctx());
assert!(report.has_blocking(false));
assert!(
report
.findings
.iter()
.any(|f| f.category == Category::Practicality)
);
}
#[test]
fn generalize_collapses_home_temp_and_email() {
let g = generalize_session_text(
"see /Users/dolores/Projects/galdr/x and /tmp/abc/y and a@b.com",
None,
);
assert!(g.contains("~/Projects/galdr/x"), "{g}");
assert!(g.contains("<temp path>"), "{g}");
assert!(g.contains("<email>"), "{g}");
assert!(!g.contains("/Users/"), "{g}");
assert!(!g.contains("dolores"), "{g}");
assert!(pii_hits(&g, None).is_empty(), "{g}");
}
#[test]
fn generalize_uses_the_home_prefix_when_given() {
let g = generalize_session_text("/opt/work/galdr/out.txt", Some("/opt/work"));
assert_eq!(g, "~/galdr/out.txt");
}
#[test]
fn detects_emails_but_not_handles_or_scopes() {
assert!(is_email_token("petru@example.com"));
assert!(!is_email_token("@scope/pkg"));
assert!(!is_email_token("@handle"));
assert!(!is_email_token("github-actions[bot]"));
}
#[test]
fn injection_phrase_and_invisible_char_warn() {
let md = format!("{COMPLETE}\n\nIgnore previous instructions and do X.\n");
let report = validate_skill(&md, &ctx());
assert!(report.findings.iter().any(|f| f.code == "SEC_INJECTION"));
assert!(!report.has_blocking(false), "injection only warns");
}
}