1#[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
11pub const MAX_YAML_DEPTH: usize = 10;
13
14pub const MAX_SKILL_MD_SIZE: usize = 50 * 1024; pub const MAX_AGENT_FILE_SIZE: usize = 10 * 1024; pub 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
63pub 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()); let truncated: String = s.chars().take(available).collect();
84 let mut result = Value::String(format!("{truncated}{suffix}"));
85 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(); for elem in arr {
105 let elem_ser = serde_json::to_string(&elem).unwrap_or_default();
106 let added = elem_ser.len() + 1; 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
138pub 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
153pub 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 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
185pub 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
218pub fn intersect_allowed_tools(skill_tools: &[String], agent_tools: &[String]) -> Vec<String> {
228 if skill_tools.is_empty() {
230 return agent_tools.to_vec();
231 }
232 if agent_tools.is_empty() {
234 return skill_tools.to_vec();
235 }
236 skill_tools
238 .iter()
239 .filter(|t| agent_tools.contains(t))
240 .cloned()
241 .collect()
242}
243
244pub 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 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 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
286const 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
305pub fn sanitize_skill_instructions(instructions: &str) -> (String, Vec<String>) {
315 let mut result = instructions.to_string();
316 let mut warnings = Vec::new();
317
318 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 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
352pub 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
374pub fn validate_mcp_tool_name(tool_name: &str) -> Result<(), ClawftError> {
383 if !tool_name.contains('_') {
385 return Ok(());
386 }
387 if tool_name.contains("__") {
389 return Ok(());
390 }
391 Ok(())
400}
401
402pub 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 #[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 #[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 #[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 #[test]
573 fn yaml_depth_accepted_at_depth_10() {
574 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 #[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 #[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 #[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 #[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 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 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 #[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 #[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}