use oatf::parse::parse;
use oatf::validate::validate;
fn errors_for(input: &str, rule: &str) -> Vec<String> {
let doc = parse(input).expect("parse should succeed");
let result = validate(&doc);
result
.errors
.iter()
.filter(|e| e.rule == rule)
.map(|e| e.path.clone())
.collect()
}
fn assert_has_error(input: &str, rule: &str) {
let doc = parse(input).expect("parse should succeed");
let result = validate(&doc);
assert!(
result.errors.iter().any(|e| e.rule == rule),
"expected error {}, got errors: {:?}, warnings: {:?}",
rule,
result.errors,
result.warnings
);
}
fn warnings_for(input: &str, code: &str) -> Vec<String> {
let doc = parse(input).expect("parse should succeed");
let result = validate(&doc);
result
.warnings
.iter()
.filter(|w| w.code == code)
.map(|w| w.message.clone())
.collect()
}
fn assert_has_warning(input: &str, code: &str) {
let doc = parse(input).expect("parse should succeed");
let result = validate(&doc);
assert!(
result.warnings.iter().any(|w| w.code == code),
"expected warning {}, got warnings: {:?}, errors: {:?}",
code,
result.warnings,
result.errors
);
}
#[test]
fn v021_valid_wildcard_paths() {
let cases = [
r#"tools[*].description"#,
r#"content[*]"#,
r#"arguments"#,
r#"skills[*].description"#,
r#"messages[*].content"#,
];
for target in &cases {
let input = format!(
r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools: []
indicators:
- target: "{}"
pattern:
target: "{}"
condition:
contains: "test"
"#,
target, target
);
let errs = errors_for(&input, "V-021");
assert!(
errs.is_empty(),
"target '{}' should be valid but got: {:?}",
target,
errs
);
}
}
#[test]
fn v021_rejects_numeric_index() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools: []
indicators:
- target: "tools[0].description"
pattern:
target: "tools[0].description"
condition:
contains: "test"
"#;
assert_has_error(input, "V-021");
}
#[test]
fn v021_rejects_double_dot() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools: []
indicators:
- target: "tools..name"
pattern:
target: "tools..name"
condition:
contains: "test"
"#;
assert_has_error(input, "V-021");
}
#[test]
fn v022_threshold_out_of_range() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools: []
indicators:
- target: "tools[*].description"
semantic:
intent: "exfiltrate data"
threshold: 1.5
"#;
assert_has_error(input, "V-022");
}
#[test]
fn v022_threshold_valid() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools: []
indicators:
- target: "tools[*].description"
semantic:
intent: "exfiltrate data"
threshold: 0.8
"#;
let errs = errors_for(input, "V-022");
assert!(
errs.is_empty(),
"valid threshold should not error: {:?}",
errs
);
}
#[test]
fn v023_valid_attack_id() {
let input = r#"
oatf: "0.1"
attack:
id: OATF-TOOL-001
execution:
mode: mcp_server
state:
tools: []
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
let errs = errors_for(input, "V-023");
assert!(
errs.is_empty(),
"valid attack ID should not error: {:?}",
errs
);
}
#[test]
fn v024_valid_indicator_id() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools: []
indicators:
- id: OATF-001-01
target: "tools[*].description"
pattern:
contains: "test"
"#;
let errs = errors_for(input, "V-024");
assert!(
errs.is_empty(),
"valid indicator ID should not error: {:?}",
errs
);
}
#[test]
fn v025_confidence_out_of_range() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools: []
indicators:
- target: "tools[*].description"
confidence: 150
pattern:
contains: "test"
"#;
assert_has_error(input, "V-025");
}
#[test]
fn v029_valid_event_for_mode() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
phases:
- name: phase-1
state:
tools: []
trigger:
event: tools/call
- name: phase-2
description: "Terminal phase."
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
let warnings = warnings_for(input, "V-029");
assert!(
warnings.is_empty(),
"valid event should not warn: {:?}",
warnings
);
}
#[test]
fn v030_state_and_phases_both_present() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools: []
phases:
- name: phase-1
state:
tools: []
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
assert_has_error(input, "V-030");
}
#[test]
fn v031_actors_must_have_unique_names() {
let input = r#"
oatf: "0.1"
attack:
execution:
actors:
- name: attacker
mode: mcp_server
phases:
- name: phase-1
state:
tools: []
- name: attacker
mode: a2a_server
phases:
- name: phase-1
state:
agent_card:
name: "Agent"
description: "Agent"
url: "https://example.com"
skills: []
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
assert_has_error(input, "V-031");
}
#[test]
fn v035_zero_version() {
let input = r#"
oatf: "0.1"
attack:
version: 0
execution:
mode: mcp_server
state:
tools: []
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
assert_has_error(input, "V-035");
}
#[test]
fn v035_negative_version() {
let input = r#"
oatf: "0.1"
attack:
version: -1
execution:
mode: mcp_server
state:
tools: []
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
assert_has_error(input, "V-035");
}
#[test]
fn v036_valid_duration() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
phases:
- name: wait
state:
tools: []
trigger:
after: "30s"
- name: exploit
description: "Terminal phase."
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
let errs = errors_for(input, "V-036");
assert!(
errs.is_empty(),
"valid duration should not error: {:?}",
errs
);
}
#[test]
fn v036_invalid_duration() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
phases:
- name: wait
state:
tools: []
trigger:
after: "invalid"
- name: exploit
description: "Terminal phase."
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
assert_has_error(input, "V-036");
}
#[test]
fn v037_valid_extractor_name() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
phases:
- name: phase-1
state:
tools: []
extractors:
- name: user_input
source: request
type: json_path
selector: "$.arguments.a"
trigger:
event: tools/call
- name: phase-2
description: "Terminal."
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
let errs = errors_for(input, "V-037");
assert!(
errs.is_empty(),
"valid extractor name should not error: {:?}",
errs
);
}
#[test]
fn v038_empty_extractors_array() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
phases:
- name: phase-1
state:
tools: []
extractors: []
trigger:
event: tools/call
- name: phase-2
description: "Terminal."
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
assert_has_error(input, "V-038");
}
#[test]
fn v040_trigger_with_only_count() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
phases:
- name: phase-1
state:
tools: []
trigger:
count: 3
- name: phase-2
description: "Terminal."
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
assert_has_error(input, "V-040");
}
#[test]
fn v041_valid_known_action() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
phases:
- name: phase-1
state:
tools: []
on_enter:
- log:
message: "Phase entered"
level: info
trigger:
event: tools/call
- name: phase-2
description: "Terminal."
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
let errs = errors_for(input, "V-041");
assert!(errs.is_empty(), "valid action should not error: {:?}", errs);
}
#[test]
fn v010_duplicate_indicator_ids() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools: []
indicators:
- id: dup-01
target: "tools[*].description"
pattern:
contains: "test"
- id: dup-01
target: "tools[*].name"
pattern:
contains: "evil"
"#;
assert_has_error(input, "V-010");
}
#[test]
fn v012_no_detection_key() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools: []
indicators:
- target: "tools[*].description"
"#;
assert_has_error(input, "V-012");
}
#[test]
fn v012_two_detection_keys() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools: []
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
semantic:
intent: "exfiltrate"
"#;
assert_has_error(input, "V-012");
}
#[test]
fn v013_invalid_regex() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools: []
indicators:
- target: "tools[*].description"
pattern:
regex: "[unclosed"
"#;
assert_has_error(input, "V-013");
}
#[test]
fn v013_valid_regex() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools: []
indicators:
- target: "tools[*].description"
pattern:
regex: "(passwd|shadow|id_rsa)"
"#;
let errs = errors_for(input, "V-013");
assert!(errs.is_empty(), "valid regex should not error: {:?}", errs);
}
fn input_with_invalid_cel_expression() -> &'static str {
r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools: []
indicators:
- target: "tools[*].description"
expression:
cel: "message.content.contains("
"#
}
#[cfg(feature = "cel-validate")]
#[test]
fn v014_invalid_cel_rejected_when_cel_validate_enabled() {
assert_has_error(input_with_invalid_cel_expression(), "V-014");
}
#[cfg(not(feature = "cel-validate"))]
#[test]
fn v014_invalid_cel_not_checked_when_cel_validate_disabled() {
let errs = errors_for(input_with_invalid_cel_expression(), "V-014");
assert!(
errs.is_empty(),
"V-014 should be skipped when cel-validate is disabled, got: {:?}",
errs
);
}
#[test]
fn v006_empty_indicators() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools: []
indicators: []
"#;
assert_has_error(input, "V-006");
}
#[test]
fn v007_empty_phases() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
phases: []
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
assert_has_error(input, "V-007");
}
#[test]
fn v019_trigger_count_zero_rejected() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
phases:
- name: phase-1
state:
tools: []
trigger:
event: tools/call
count: 0
- name: phase-2
description: "Terminal."
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
assert_has_error(input, "V-019");
}
#[test]
fn v019_trigger_count_negative_rejected() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
phases:
- name: phase-1
state:
tools: []
trigger:
event: tools/call
count: -1
- name: phase-2
description: "Terminal."
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
assert_has_error(input, "V-019");
}
#[test]
fn v016_unclosed_template_in_on_enter_rejected() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
phases:
- name: phase-1
state:
tools: []
on_enter:
- log:
message: "started {{unclosed"
trigger:
event: tools/call
- name: phase-2
description: "Terminal."
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
assert_has_error(input, "V-016");
}
#[test]
fn v032_single_phase_unknown_actor_reference_rejected() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools:
- name: t1
description: "use {{ghost.extractor}}"
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
assert_has_error(input, "V-032");
}
#[test]
fn w004_single_phase_undeclared_extractor_warns() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools:
- name: t1
description: "use {{missing_extractor}}"
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
let warnings = warnings_for(input, "W-004");
assert!(
!warnings.is_empty(),
"undeclared extractor in single-phase state should emit W-004"
);
}
#[test]
fn v042_regex_with_literal_parenthesis_and_no_group_rejected() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
phases:
- name: phase-1
state:
tools: []
extractors:
- name: lit
source: request
type: regex
selector: "[(]"
trigger:
event: tools/call
- name: phase-2
description: "Terminal."
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
assert_has_error(input, "V-042");
}
#[test]
fn parse_rejects_shorthand_pattern_wrong_type() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools: []
indicators:
- target: "tools[*].description"
pattern:
contains: 123
"#;
assert!(parse(input).is_err(), "contains:number must fail parse");
}
#[test]
fn parse_rejects_condition_operator_wrong_type() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools: []
indicators:
- target: "tools[*].description"
pattern:
condition:
contains: 123
"#;
assert!(
parse(input).is_err(),
"pattern.condition.contains:number must fail parse"
);
}
#[test]
fn parse_allows_inline_comment_with_merge_marker_text() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools: [] # docs mention <<: merge syntax here
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
assert!(
parse(input).is_ok(),
"inline comment containing <<: should not fail parse"
);
}
#[test]
fn parse_allows_inline_comment_with_anchor_text_on_flow_value() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools: [1] # &anchor text in comment
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
assert!(
parse(input).is_ok(),
"inline comment containing &name should not fail parse"
);
}
#[test]
fn v013_invalid_regex_in_response_when_rejected() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
phases:
- name: phase-1
state:
tools:
- name: tool1
description: ""
responses:
- when:
arguments.command:
regex: "[unterminated"
content:
- type: text
text: "ok"
trigger:
event: tools/call
- name: phase-2
description: "Terminal."
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
assert_has_error(input, "V-013");
}
#[test]
fn v027_invalid_key_in_response_when_rejected() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
phases:
- name: phase-1
state:
tools:
- name: tool1
description: ""
responses:
- when:
"bad key":
exists: true
content:
- type: text
text: "ok"
trigger:
event: tools/call
- name: phase-2
description: "Terminal."
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
assert_has_error(input, "V-027");
}
#[test]
fn v027_ignores_non_response_when_object() {
let input = r##"
oatf: "0.1"
attack:
execution:
mode: mcp_server
phases:
- name: phase-1
state:
tools:
- name: tool1
description: ""
inputSchema:
type: object
properties:
when:
$ref: "#/defs/input"
trigger:
event: tools/call
- name: phase-2
description: "Terminal."
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"##;
let errs = errors_for(input, "V-027");
assert!(
errs.is_empty(),
"non-response `when` objects must not trigger V-027: {:?}",
errs
);
}
#[test]
fn v013_ignores_non_response_when_object() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
phases:
- name: phase-1
state:
tools:
- name: tool1
description: ""
inputSchema:
type: object
properties:
when:
foo:
regex: "[unterminated"
trigger:
event: tools/call
- name: phase-2
description: "Terminal."
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
let errs = errors_for(input, "V-013");
assert!(
errs.is_empty(),
"non-response `when` objects must not trigger V-013: {:?}",
errs
);
}
#[test]
fn parse_rejects_unknown_field_inside_known_action_payload() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
phases:
- name: phase-1
state:
tools: []
on_enter:
- send:
method: "notifications/tools/list_changed"
typo: true
trigger:
event: tools/call
- name: phase-2
description: "Terminal."
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
assert!(
parse(input).is_err(),
"unknown inner fields on known actions must fail parse"
);
}
#[test]
fn v018_custom_protocol_skips_surface_validation() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: custom_server
state:
tools: []
indicators:
- surface: custom_surface
target: "some.path"
protocol: custom_proto
pattern:
target: "some.path"
contains: "test"
"#;
let warnings = warnings_for(input, "V-018");
assert!(
warnings.is_empty(),
"custom protocol indicators must not produce V-018 warnings, got: {:?}",
warnings
);
}
#[test]
fn v018_known_protocol_warns_on_unknown_surface() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools: []
indicators:
- surface: bogus_surface
target: "tools[*].description"
pattern:
contains: "test"
"#;
assert_has_warning(input, "V-018");
}
#[test]
fn state_type_rejects_non_object_state() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state: 42
"#;
let errs = errors_for(input, "state-type");
assert!(
!errs.is_empty(),
"non-object state (number) must be rejected"
);
}
#[test]
fn state_type_rejects_string_state() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state: "hello"
"#;
let errs = errors_for(input, "state-type");
assert!(
!errs.is_empty(),
"non-object state (string) must be rejected"
);
}
#[test]
fn state_type_accepts_object_state() {
let input = r#"
oatf: "0.1"
attack:
execution:
mode: mcp_server
state:
tools: []
indicators:
- target: "tools[*].description"
pattern:
contains: "test"
"#;
let errs = errors_for(input, "state-type");
assert!(
errs.is_empty(),
"object state must not produce state-type errors, got: {:?}",
errs
);
}