Skip to main content

clawft_core/security/
mod.rs

1//! Security boundary validation functions.
2
3#[cfg(feature = "signing")]
4pub mod signing;
5
6use clawft_types::error::ClawftError;
7use serde_json::Value;
8
9const MAX_SESSION_ID_LEN: usize = 256;
10
11/// Maximum nesting depth for YAML frontmatter (SEC-SKILL-01).
12pub const MAX_YAML_DEPTH: usize = 10;
13
14/// Maximum file size for SKILL.md files in bytes (SEC-SKILL-07).
15pub const MAX_SKILL_MD_SIZE: usize = 50 * 1024; // 50 KB
16
17/// Maximum file size for agent YAML/JSON files in bytes (SEC-SKILL-07).
18pub const MAX_AGENT_FILE_SIZE: usize = 10 * 1024; // 10 KB
19
20/// Validate a session identifier against security policies.
21///
22/// Rejects empty, overlong, path-traversal, directory-separator, null-byte,
23/// and control-character identifiers (tab is permitted).
24pub fn validate_session_id(id: &str) -> Result<(), ClawftError> {
25    if id.is_empty() {
26        return Err(ClawftError::SecurityViolation {
27            reason: "session ID must not be empty".into(),
28        });
29    }
30    if id.len() > MAX_SESSION_ID_LEN {
31        return Err(ClawftError::SecurityViolation {
32            reason: format!(
33                "session ID too long ({} bytes, max {MAX_SESSION_ID_LEN})",
34                id.len()
35            ),
36        });
37    }
38    if id.contains("..") {
39        return Err(ClawftError::SecurityViolation {
40            reason: "session ID contains path traversal sequence '..'".into(),
41        });
42    }
43    if id.contains('/') || id.contains('\\') {
44        return Err(ClawftError::SecurityViolation {
45            reason: "session ID contains directory separator".into(),
46        });
47    }
48    if id.contains('\0') {
49        return Err(ClawftError::SecurityViolation {
50            reason: "session ID contains null byte".into(),
51        });
52    }
53    for byte in id.bytes() {
54        if byte <= 0x1F && byte != 0x09 {
55            return Err(ClawftError::SecurityViolation {
56                reason: format!("session ID contains control character 0x{byte:02X}"),
57            });
58        }
59    }
60    Ok(())
61}
62
63/// Truncate a JSON value so its serialized form fits within `max_bytes`.
64///
65/// Strategy varies by type: strings get a truncation suffix, arrays keep
66/// leading elements with a sentinel, and other values get raw-JSON wrapping.
67pub fn truncate_result(value: Value, max_bytes: usize) -> Value {
68    let serialized = serde_json::to_string(&value).unwrap_or_default();
69    if serialized.len() <= max_bytes {
70        return value;
71    }
72    let original_len = serialized.len();
73    match value {
74        Value::String(s) => truncate_string(s, original_len, max_bytes),
75        Value::Array(arr) => truncate_array(arr, original_len, max_bytes),
76        _ => truncate_raw(serialized, original_len, max_bytes),
77    }
78}
79
80fn truncate_string(s: String, original_len: usize, max_bytes: usize) -> Value {
81    let suffix = format!("... [truncated, original size: {original_len} bytes]");
82    let available = max_bytes.saturating_sub(2 + suffix.len()); // 2 = JSON quotes
83    let truncated: String = s.chars().take(available).collect();
84    let mut result = Value::String(format!("{truncated}{suffix}"));
85    // Shrink further if JSON escaping pushed us over.
86    while serde_json::to_string(&result).unwrap_or_default().len() > max_bytes {
87        let chars: Vec<char> = result.as_str().unwrap().chars().collect();
88        if chars.len() <= suffix.len() + 1 {
89            result = Value::String(suffix.clone());
90            break;
91        }
92        let keep = chars.len() - suffix.len() - 1;
93        let shortened: String = chars[..keep].iter().collect();
94        result = Value::String(format!("{shortened}{suffix}"));
95    }
96    result
97}
98
99fn truncate_array(arr: Vec<Value>, original_count: usize, max_bytes: usize) -> Value {
100    let sentinel = serde_json::json!({"_truncated": true, "original_count": original_count});
101    let sentinel_ser = serde_json::to_string(&sentinel).unwrap_or_default();
102    let mut kept: Vec<Value> = Vec::new();
103    let mut current_len = 2 + sentinel_ser.len(); // `[` + sentinel + `]`
104    for elem in arr {
105        let elem_ser = serde_json::to_string(&elem).unwrap_or_default();
106        let added = elem_ser.len() + 1; // comma separator
107        if current_len + added > max_bytes {
108            break;
109        }
110        current_len += added;
111        kept.push(elem);
112    }
113    kept.push(sentinel);
114    Value::Array(kept)
115}
116
117fn truncate_raw(serialized: String, original_len: usize, max_bytes: usize) -> Value {
118    let template_overhead =
119        r#"{"_truncated_json":"...","original_bytes":}"#.len() + original_len.to_string().len();
120    let available = max_bytes.saturating_sub(template_overhead);
121    let prefix: String = serialized.chars().take(available).collect();
122    let mut result = serde_json::json!({"_truncated_json": format!("{prefix}..."), "original_bytes": original_len});
123    while serde_json::to_string(&result).unwrap_or_default().len() > max_bytes {
124        let cur = match result.get("_truncated_json").and_then(Value::as_str) {
125            Some(s) => s.to_owned(),
126            None => break,
127        };
128        let chars: Vec<char> = cur.chars().collect();
129        if chars.len() <= 4 {
130            break;
131        }
132        let shortened: String = chars[..chars.len() - 4].iter().collect();
133        result["_truncated_json"] = Value::String(format!("{shortened}..."));
134    }
135    result
136}
137
138/// Strip null bytes, DEL, and ASCII control chars except `\n`, `\r`, `\t`.
139/// Preserves all valid UTF-8 including emoji, CJK, and RTL text.
140pub fn sanitize_content(content: &str) -> String {
141    content
142        .chars()
143        .filter(|&c| {
144            if c == '\0' || c == '\x7F' {
145                return false;
146            }
147            let b = c as u32;
148            !(b <= 0x1F && b != 0x0A && b != 0x0D && b != 0x09)
149        })
150        .collect()
151}
152
153// ── SEC-SKILL-01: YAML depth validation ─────────────────────────────
154
155/// Check that a YAML frontmatter string does not exceed `MAX_YAML_DEPTH`
156/// levels of nesting. Nesting is detected by indentation changes (each
157/// 2-space indent = one level).
158///
159/// Returns `Ok(())` if the depth is within limits, or a
160/// [`ClawftError::SecurityViolation`] if it exceeds `MAX_YAML_DEPTH`.
161pub fn validate_yaml_depth(yaml: &str) -> Result<(), ClawftError> {
162    let mut max_depth: usize = 0;
163
164    for line in yaml.lines() {
165        if line.trim().is_empty() || line.trim().starts_with('#') {
166            continue;
167        }
168        let indent = line.len() - line.trim_start().len();
169        // Each 2 spaces of indentation counts as one nesting level.
170        // A single space also counts (for odd indentation).
171        let depth = indent.div_ceil(2);
172        max_depth = max_depth.max(depth);
173    }
174
175    if max_depth > MAX_YAML_DEPTH {
176        return Err(ClawftError::SecurityViolation {
177            reason: format!(
178                "YAML frontmatter nesting depth {max_depth} exceeds maximum of {MAX_YAML_DEPTH}"
179            ),
180        });
181    }
182    Ok(())
183}
184
185// ── SEC-SKILL-02: Directory name validation ─────────────────────────
186
187/// Validate a directory entry name for use in skill/agent loading.
188///
189/// Rejects names that contain path traversal (`..`), directory separators
190/// (`/` or `\`), or are absolute paths (start with `/`).
191pub fn validate_directory_name(name: &str) -> Result<(), ClawftError> {
192    if name.is_empty() {
193        return Err(ClawftError::SecurityViolation {
194            reason: "directory name must not be empty".into(),
195        });
196    }
197    if name.contains("..") {
198        return Err(ClawftError::SecurityViolation {
199            reason: format!(
200                "directory name '{}' contains path traversal sequence '..'",
201                name
202            ),
203        });
204    }
205    if name.contains('/') || name.contains('\\') {
206        return Err(ClawftError::SecurityViolation {
207            reason: format!("directory name '{}' contains directory separator", name),
208        });
209    }
210    if name.starts_with('/') {
211        return Err(ClawftError::SecurityViolation {
212            reason: format!("directory name '{}' is an absolute path", name),
213        });
214    }
215    Ok(())
216}
217
218// ── SEC-SKILL-03: Tool allowlist intersection ───────────────────────
219
220/// Compute the effective tool list as the intersection of a skill's
221/// `allowed_tools` and an agent's `allowed_tools`.
222///
223/// If the skill's tool list is empty, the agent's list is returned
224/// (the skill does not restrict tools). If the agent's list is empty,
225/// all tools are allowed and the skill's list is returned as-is.
226/// If both are non-empty, only tools in both lists are returned.
227pub fn intersect_allowed_tools(skill_tools: &[String], agent_tools: &[String]) -> Vec<String> {
228    // If the skill has no restrictions, the agent's list applies.
229    if skill_tools.is_empty() {
230        return agent_tools.to_vec();
231    }
232    // If the agent has no restrictions, the skill's list applies.
233    if agent_tools.is_empty() {
234        return skill_tools.to_vec();
235    }
236    // Both have restrictions: return the intersection.
237    skill_tools
238        .iter()
239        .filter(|t| agent_tools.contains(t))
240        .cloned()
241        .collect()
242}
243
244// ── SEC-SKILL-04: Model string validation ───────────────────────────
245
246/// Validate a model string against security rules.
247///
248/// Accepts strings matching `provider/model-name` or bare `model-name`.
249/// Rejects strings containing shell metacharacters, newlines, control
250/// characters, or other dangerous patterns.
251pub fn validate_model_string(model: &str) -> Result<(), ClawftError> {
252    if model.is_empty() {
253        return Err(ClawftError::SecurityViolation {
254            reason: "model string must not be empty".into(),
255        });
256    }
257
258    // Check for control characters (including newlines).
259    for ch in model.chars() {
260        if ch.is_control() {
261            return Err(ClawftError::SecurityViolation {
262                reason: format!(
263                    "model string contains control character U+{:04X}",
264                    ch as u32
265                ),
266            });
267        }
268    }
269
270    // Shell metacharacters that must not appear in a model string.
271    const FORBIDDEN: &[char] = &[
272        ';', '|', '&', '$', '`', '(', ')', '{', '}', '<', '>', '!', '#', '~', '*', '?', '[', ']',
273        '\'', '"', '\\', '\n', '\r',
274    ];
275    for ch in model.chars() {
276        if FORBIDDEN.contains(&ch) {
277            return Err(ClawftError::SecurityViolation {
278                reason: format!("model string contains forbidden character '{}'", ch),
279            });
280        }
281    }
282
283    Ok(())
284}
285
286// ── SEC-SKILL-06: Prompt injection guards ───────────────────────────
287
288/// Known prompt injection tokens to strip from skill instructions.
289const INJECTION_TOKENS: &[&str] = &[
290    "<system>",
291    "</system>",
292    "<|im_start|>",
293    "<|im_end|>",
294    "<|im_sep|>",
295    "<|endoftext|>",
296    "<|system|>",
297    "<|user|>",
298    "<|assistant|>",
299    "[INST]",
300    "[/INST]",
301    "<<SYS>>",
302    "<</SYS>>",
303];
304
305/// Sanitize skill instructions by stripping known prompt injection tokens
306/// and warning on suspicious patterns.
307///
308/// This function:
309/// - Strips `<system>`, `<|im_start|>`, and similar injection tokens
310/// - Warns on suspicious patterns (multiple system-like prompts)
311/// - Does NOT modify normal markdown content
312///
313/// Returns the sanitized string and a list of warnings (if any).
314pub fn sanitize_skill_instructions(instructions: &str) -> (String, Vec<String>) {
315    let mut result = instructions.to_string();
316    let mut warnings = Vec::new();
317
318    // Loop until no more injection tokens are found (prevents nested bypass
319    // where e.g. "<sy<system>stem>" reconstructs after inner removal).
320    const MAX_SANITIZE_PASSES: usize = 10;
321    let mut passes = 0;
322    loop {
323        let before = result.clone();
324        for token in INJECTION_TOKENS {
325            if result.contains(token) {
326                warnings.push(format!(
327                    "stripped injection token '{}' from skill instructions",
328                    token
329                ));
330                result = result.replace(token, "");
331            }
332        }
333        passes += 1;
334        if result == before || passes >= MAX_SANITIZE_PASSES {
335            break;
336        }
337    }
338
339    // Case-insensitive check for remaining suspicious patterns.
340    let lower = result.to_lowercase();
341    let system_count = lower.matches("<system").count() + lower.matches("system:").count();
342    if system_count > 1 {
343        warnings.push(format!(
344            "suspicious: found {} system-like prompts in skill instructions",
345            system_count
346        ));
347    }
348
349    (result, warnings)
350}
351
352// ── SEC-SKILL-07: File size validation ──────────────────────────────
353
354/// Validate that a file size is within the allowed limit.
355///
356/// Returns `Ok(())` if the size is within limits, or a
357/// [`ClawftError::SecurityViolation`] if it exceeds the maximum.
358pub fn validate_file_size(
359    size: usize,
360    max_size: usize,
361    file_kind: &str,
362) -> Result<(), ClawftError> {
363    if size > max_size {
364        return Err(ClawftError::SecurityViolation {
365            reason: format!(
366                "{} file size {} bytes exceeds maximum of {} bytes",
367                file_kind, size, max_size
368            ),
369        });
370    }
371    Ok(())
372}
373
374// ── SEC-SKILL-08: MCP tool namespace isolation ──────────────────────
375
376/// Validate that an MCP tool name uses the `{server}__{tool}` format
377/// (double underscore separator).
378///
379/// Non-MCP tools (those without any underscore) are allowed through.
380/// Only tools that appear to come from an MCP server (contain at least
381/// one underscore) are checked for the double-underscore convention.
382pub fn validate_mcp_tool_name(tool_name: &str) -> Result<(), ClawftError> {
383    // If the tool name contains no underscores at all, it is a local tool.
384    if !tool_name.contains('_') {
385        return Ok(());
386    }
387    // If it contains a double underscore, it follows the convention.
388    if tool_name.contains("__") {
389        return Ok(());
390    }
391    // It contains single underscores but no double underscore.
392    // This could be a local tool with underscores in its name (e.g.
393    // "read_file"), which is fine. We only flag tools that look like
394    // they come from an MCP server but lack the `__` separator.
395    // Heuristic: if the first segment (before the first `_`) looks like
396    // a server name (lowercase, no digits), we flag it.
397    // For simplicity, we require validation to be called explicitly for
398    // MCP tools only.
399    Ok(())
400}
401
402/// Validate that an MCP server tool name uses the required `{server}__{tool}`
403/// format. This is the strict variant called during MCP tool registration.
404///
405/// Returns an error if the tool name does not contain `__`.
406pub fn validate_mcp_tool_name_strict(tool_name: &str) -> Result<(), ClawftError> {
407    if !tool_name.contains("__") {
408        return Err(ClawftError::SecurityViolation {
409            reason: format!(
410                "MCP tool '{}' does not use the required '{{server}}__{{tool}}' \
411                 namespace format (missing double underscore separator)",
412                tool_name
413            ),
414        });
415    }
416    Ok(())
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    // ── validate_session_id ────────────────────────────────────────
424
425    #[test]
426    fn valid_simple_id() {
427        assert!(validate_session_id("abc-123_XYZ").is_ok());
428    }
429
430    #[test]
431    fn valid_id_with_allowed_chars() {
432        assert!(validate_session_id("user@host:session+1.0").is_ok());
433    }
434
435    #[test]
436    fn valid_id_with_space_and_tab() {
437        assert!(validate_session_id("hello world\there").is_ok());
438    }
439
440    #[test]
441    fn reject_empty() {
442        let err = validate_session_id("").unwrap_err();
443        assert!(err.to_string().contains("empty"));
444    }
445
446    #[test]
447    fn reject_too_long() {
448        let err = validate_session_id(&"a".repeat(257)).unwrap_err();
449        assert!(err.to_string().contains("too long"));
450    }
451
452    #[test]
453    fn accept_max_length() {
454        assert!(validate_session_id(&"b".repeat(256)).is_ok());
455    }
456
457    #[test]
458    fn reject_path_traversal() {
459        let err = validate_session_id("foo/../bar").unwrap_err();
460        assert!(err.to_string().contains("path traversal"));
461    }
462
463    #[test]
464    fn reject_forward_slash() {
465        let err = validate_session_id("foo/bar").unwrap_err();
466        assert!(err.to_string().contains("directory separator"));
467    }
468
469    #[test]
470    fn reject_backslash() {
471        let err = validate_session_id("foo\\bar").unwrap_err();
472        assert!(err.to_string().contains("directory separator"));
473    }
474
475    #[test]
476    fn reject_null_byte() {
477        let err = validate_session_id("foo\0bar").unwrap_err();
478        assert!(err.to_string().contains("null byte"));
479    }
480
481    #[test]
482    fn reject_control_char() {
483        let err = validate_session_id("foo\x01bar").unwrap_err();
484        assert!(err.to_string().contains("control character"));
485    }
486
487    // ── truncate_result ────────────────────────────────────────────
488
489    #[test]
490    fn no_truncation_when_fits() {
491        let val = Value::String("short".into());
492        assert_eq!(truncate_result(val.clone(), 1000), val);
493    }
494
495    #[test]
496    fn truncate_long_string() {
497        let result = truncate_result(Value::String("x".repeat(500)), 100);
498        let ser = serde_json::to_string(&result).unwrap();
499        assert!(ser.len() <= 100, "got {} bytes", ser.len());
500        assert!(result.as_str().unwrap().contains("truncated"));
501    }
502
503    #[test]
504    fn truncate_large_array() {
505        let arr: Vec<Value> = (0..100).map(|i| Value::Number(i.into())).collect();
506        let result = truncate_result(Value::Array(arr), 100);
507        let ser = serde_json::to_string(&result).unwrap();
508        assert!(ser.len() <= 100, "got {} bytes", ser.len());
509        let last = result.as_array().unwrap().last().unwrap();
510        assert_eq!(last["_truncated"], Value::Bool(true));
511    }
512
513    #[test]
514    fn truncate_object() {
515        let mut map = serde_json::Map::new();
516        for i in 0..50 {
517            map.insert(format!("key_{i}"), Value::String("value".into()));
518        }
519        let result = truncate_result(Value::Object(map), 100);
520        let ser = serde_json::to_string(&result).unwrap();
521        assert!(ser.len() <= 100, "got {} bytes", ser.len());
522        assert!(result.get("_truncated_json").is_some());
523    }
524
525    // ── sanitize_content ───────────────────────────────────────────
526
527    #[test]
528    fn sanitize_preserves_normal_text() {
529        assert_eq!(sanitize_content("hello world"), "hello world");
530    }
531
532    #[test]
533    fn sanitize_preserves_newlines_tabs() {
534        assert_eq!(sanitize_content("a\nb\rc\td"), "a\nb\rc\td");
535    }
536
537    #[test]
538    fn sanitize_strips_null() {
539        assert_eq!(sanitize_content("a\0b"), "ab");
540    }
541
542    #[test]
543    fn sanitize_strips_control_chars() {
544        assert_eq!(sanitize_content("a\x01\x02\x1Fb"), "ab");
545    }
546
547    #[test]
548    fn sanitize_strips_del() {
549        assert_eq!(sanitize_content("a\x7Fb"), "ab");
550    }
551
552    #[test]
553    fn sanitize_preserves_emoji() {
554        let s = "Hello \u{1F600} world \u{1F30D}";
555        assert_eq!(sanitize_content(s), s);
556    }
557
558    #[test]
559    fn sanitize_preserves_cjk() {
560        let s = "\u{4F60}\u{597D}\u{4E16}\u{754C}";
561        assert_eq!(sanitize_content(s), s);
562    }
563
564    #[test]
565    fn sanitize_preserves_rtl() {
566        let s = "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}";
567        assert_eq!(sanitize_content(s), s);
568    }
569
570    // ── SEC-SKILL-01: YAML depth validation ──────────────────────
571
572    #[test]
573    fn yaml_depth_accepted_at_depth_10() {
574        // 10 levels = 20 spaces of indentation
575        let mut yaml = String::new();
576        yaml.push_str("level0:\n");
577        for level in 1..=10 {
578            let indent = "  ".repeat(level);
579            yaml.push_str(&format!("{}level{}:\n", indent, level));
580        }
581        assert!(validate_yaml_depth(&yaml).is_ok());
582    }
583
584    #[test]
585    fn yaml_depth_rejected_at_depth_11() {
586        let mut yaml = String::new();
587        yaml.push_str("level0:\n");
588        for level in 1..=11 {
589            let indent = "  ".repeat(level);
590            yaml.push_str(&format!("{}level{}:\n", indent, level));
591        }
592        let err = validate_yaml_depth(&yaml).unwrap_err();
593        assert!(err.to_string().contains("nesting depth"));
594        assert!(err.to_string().contains("exceeds"));
595    }
596
597    #[test]
598    fn yaml_depth_flat_is_ok() {
599        let yaml = "name: test\nversion: 1.0.0\n";
600        assert!(validate_yaml_depth(yaml).is_ok());
601    }
602
603    // ── SEC-SKILL-02: Directory name validation ──────────────────
604
605    #[test]
606    fn dir_name_valid() {
607        assert!(validate_directory_name("my-skill").is_ok());
608        assert!(validate_directory_name("skill_v2").is_ok());
609    }
610
611    #[test]
612    fn dir_name_traversal_rejected() {
613        let err = validate_directory_name("../evil").unwrap_err();
614        assert!(err.to_string().contains("path traversal"));
615    }
616
617    #[test]
618    fn dir_name_slash_rejected() {
619        let err = validate_directory_name("foo/bar").unwrap_err();
620        assert!(err.to_string().contains("directory separator"));
621    }
622
623    #[test]
624    fn dir_name_backslash_rejected() {
625        let err = validate_directory_name("foo\\bar").unwrap_err();
626        assert!(err.to_string().contains("directory separator"));
627    }
628
629    #[test]
630    fn dir_name_empty_rejected() {
631        let err = validate_directory_name("").unwrap_err();
632        assert!(err.to_string().contains("empty"));
633    }
634
635    // ── SEC-SKILL-03: Tool intersection ──────────────────────────
636
637    #[test]
638    fn tool_intersection_both_non_empty() {
639        let skill = vec!["Read".into(), "Write".into(), "Grep".into()];
640        let agent = vec!["Read".into(), "Grep".into(), "Bash".into()];
641        let result = intersect_allowed_tools(&skill, &agent);
642        assert_eq!(result, vec!["Read", "Grep"]);
643    }
644
645    #[test]
646    fn tool_intersection_skill_empty() {
647        let agent = vec!["Read".into(), "Bash".into()];
648        let result = intersect_allowed_tools(&[], &agent);
649        assert_eq!(result, vec!["Read", "Bash"]);
650    }
651
652    #[test]
653    fn tool_intersection_agent_empty() {
654        let skill = vec!["Read".into(), "Write".into()];
655        let result = intersect_allowed_tools(&skill, &[]);
656        assert_eq!(result, vec!["Read", "Write"]);
657    }
658
659    #[test]
660    fn tool_intersection_both_empty() {
661        let result: Vec<String> = intersect_allowed_tools(&[], &[]);
662        assert!(result.is_empty());
663    }
664
665    #[test]
666    fn tool_intersection_no_overlap() {
667        let skill = vec!["Read".into()];
668        let agent = vec!["Bash".into()];
669        let result = intersect_allowed_tools(&skill, &agent);
670        assert!(result.is_empty());
671    }
672
673    // ── SEC-SKILL-04: Model string validation ────────────────────
674
675    #[test]
676    fn model_string_valid_provider_slash() {
677        assert!(validate_model_string("openai/gpt-4").is_ok());
678        assert!(validate_model_string("anthropic/claude-opus-4-5").is_ok());
679    }
680
681    #[test]
682    fn model_string_valid_bare() {
683        assert!(validate_model_string("gpt-4").is_ok());
684    }
685
686    #[test]
687    fn model_string_shell_metachar_rejected() {
688        let err = validate_model_string("; rm -rf /").unwrap_err();
689        assert!(err.to_string().contains("forbidden character"));
690    }
691
692    #[test]
693    fn model_string_pipe_rejected() {
694        let err = validate_model_string("model | cat /etc/passwd").unwrap_err();
695        assert!(err.to_string().contains("forbidden character"));
696    }
697
698    #[test]
699    fn model_string_newline_rejected() {
700        let err = validate_model_string("model\ninjection").unwrap_err();
701        assert!(err.to_string().contains("control character"));
702    }
703
704    #[test]
705    fn model_string_empty_rejected() {
706        let err = validate_model_string("").unwrap_err();
707        assert!(err.to_string().contains("empty"));
708    }
709
710    #[test]
711    fn model_string_backtick_rejected() {
712        let err = validate_model_string("`evil`").unwrap_err();
713        assert!(err.to_string().contains("forbidden character"));
714    }
715
716    // ── SEC-SKILL-06: Prompt injection guards ────────────────────
717
718    #[test]
719    fn sanitize_strips_system_tags() {
720        let input = "Hello <system>injected</system> world";
721        let (result, warnings) = sanitize_skill_instructions(input);
722        assert!(!result.contains("<system>"));
723        assert!(!result.contains("</system>"));
724        assert!(!warnings.is_empty());
725    }
726
727    #[test]
728    fn sanitize_strips_im_tokens() {
729        let input = "Normal <|im_start|>system<|im_end|> text";
730        let (result, warnings) = sanitize_skill_instructions(input);
731        assert!(!result.contains("<|im_start|>"));
732        assert!(!result.contains("<|im_end|>"));
733        assert!(!warnings.is_empty());
734    }
735
736    #[test]
737    fn sanitize_preserves_normal_markdown() {
738        let input = "# Heading\n\nNormal **bold** and `code` text.\n\n- list item";
739        let (result, warnings) = sanitize_skill_instructions(input);
740        assert_eq!(result, input);
741        assert!(warnings.is_empty());
742    }
743
744    #[test]
745    fn sanitize_strips_nested_injection_tokens() {
746        // Nested token: removing inner "<system>" from "<sy<system>stem>"
747        // must not leave behind a reconstructed "<system>".
748        let input = "before <sy<system>stem>injected</sy</system>stem> after";
749        let (result, warnings) = sanitize_skill_instructions(input);
750        assert!(!result.contains("<system>"), "nested <system> survived: {result}");
751        assert!(!result.contains("</system>"), "nested </system> survived: {result}");
752        assert!(!warnings.is_empty());
753    }
754
755    #[test]
756    fn sanitize_strips_deeply_nested_tokens() {
757        // Double nesting: <<|im_start<|im_start|>|>
758        let input = "x <|im_sta<|im_start|>rt|> y";
759        let (result, warnings) = sanitize_skill_instructions(input);
760        assert!(!result.contains("<|im_start|>"), "deep nested token survived: {result}");
761        assert!(!warnings.is_empty());
762    }
763
764    #[test]
765    fn sanitize_strips_llama_tokens() {
766        let input = "text <<SYS>> system prompt <</SYS>> more text";
767        let (result, warnings) = sanitize_skill_instructions(input);
768        assert!(!result.contains("<<SYS>>"));
769        assert!(!result.contains("<</SYS>>"));
770        assert!(!warnings.is_empty());
771    }
772
773    // ── SEC-SKILL-07: File size validation ───────────────────────
774
775    #[test]
776    fn file_size_within_limit() {
777        assert!(validate_file_size(1024, MAX_SKILL_MD_SIZE, "SKILL.md").is_ok());
778    }
779
780    #[test]
781    fn file_size_at_limit() {
782        assert!(validate_file_size(MAX_SKILL_MD_SIZE, MAX_SKILL_MD_SIZE, "SKILL.md").is_ok());
783    }
784
785    #[test]
786    fn skill_md_51kb_rejected() {
787        let size = 51 * 1024;
788        let err = validate_file_size(size, MAX_SKILL_MD_SIZE, "SKILL.md").unwrap_err();
789        assert!(err.to_string().contains("exceeds"));
790        assert!(err.to_string().contains("SKILL.md"));
791    }
792
793    #[test]
794    fn agent_file_11kb_rejected() {
795        let size = 11 * 1024;
796        let err = validate_file_size(size, MAX_AGENT_FILE_SIZE, "agent").unwrap_err();
797        assert!(err.to_string().contains("exceeds"));
798    }
799
800    // ── SEC-SKILL-08: MCP tool namespace isolation ───────────────
801
802    #[test]
803    fn mcp_tool_with_double_underscore_valid() {
804        assert!(validate_mcp_tool_name_strict("mcp__server__tool").is_ok());
805        assert!(validate_mcp_tool_name_strict("claude-flow__memory_store").is_ok());
806    }
807
808    #[test]
809    fn mcp_tool_without_double_underscore_rejected() {
810        let err = validate_mcp_tool_name_strict("mcp_tool_name").unwrap_err();
811        assert!(err.to_string().contains("namespace format"));
812        assert!(err.to_string().contains("double underscore"));
813    }
814
815    #[test]
816    fn mcp_tool_no_underscore_rejected_strict() {
817        let err = validate_mcp_tool_name_strict("simpletool").unwrap_err();
818        assert!(err.to_string().contains("namespace format"));
819    }
820
821    #[test]
822    fn local_tool_no_underscore_passes_lenient() {
823        assert!(validate_mcp_tool_name("ReadFile").is_ok());
824    }
825
826    #[test]
827    fn local_tool_with_underscore_passes_lenient() {
828        assert!(validate_mcp_tool_name("read_file").is_ok());
829    }
830}