diff --git a/src/domain/detector.rs b/src/domain/detector.rs index 3ddf9bc..64a9a30 100644 --- a/src/domain/detector.rs +++ b/src/domain/detector.rs @@ -56,7 +56,7 @@ pub struct DetectionReport { impl DetectionReport { #[must_use] pub fn backend_count_for_sandbox(&self) -> usize { - self.total_count + self.kanji_hits } } @@ -234,4 +234,20 @@ mod tests { let kanji_word = detector.detect("大きい"); assert!(kanji_word.total_count >= 1); } + + #[test] + fn sandbox_backend_count_excludes_literal_sequence_hits() { + let report = super::DetectionReport { + backend: DetectorBackendKind::MorphologicalReading, + matched_backend: "morphological_reading", + matched_readings: vec![], + sequence_hits: 1, + kanji_hits: 0, + total_count: 1, + special_phrase_hit: false, + token_count: 0, + }; + + assert_eq!(report.backend_count_for_sandbox(), 0); + } } diff --git a/src/security/response_compiler.rs b/src/security/response_compiler.rs index 9347164..d2eb3c5 100644 --- a/src/security/response_compiler.rs +++ b/src/security/response_compiler.rs @@ -89,10 +89,10 @@ fn proposal_to_action( ActionProposal::SpecialPhrase => BotAction::SendMessage { content: bot.stamp_text.clone() }, ActionProposal::ReactOnce => as_react(bot), ActionProposal::SendStamped { count } => { - if *count <= 1 { + let repeats = (*count as usize).min(detector_total_count.max(1)); + if repeats <= 1 { as_react(bot) } else { - let repeats = (*count as usize).min(detector_total_count.max(1)); let rendered = render_template( &bot.send_template, repeats, @@ -244,4 +244,23 @@ mod tests { assert_eq!(action, BotAction::Noop); } + + #[test] + fn caps_send_decision_to_trusted_detector_count() { + let cfg = BotConfig::default(); + + let action = compile_response(&CompileContext { + proposal: &ActionProposal::SendStamped { count: 2 }, + mode: RuntimeMode::Normal, + suspicion: SuspicionLevel::None, + bot: &cfg, + max_send_chars: 300, + matched_backend: "morphological_reading", + matched_reading: None, + count_cap: cfg.max_count_cap, + detector_total_count: 1, + }); + + assert!(matches!(action, BotAction::React { .. })); + } } diff --git a/tests/runtime_protection_integration.rs b/tests/runtime_protection_integration.rs index 392365a..f1ddb2a 100644 --- a/tests/runtime_protection_integration.rs +++ b/tests/runtime_protection_integration.rs @@ -39,12 +39,20 @@ fn governed_path_keeps_existing_behavior_for_mixed_input() { assert!(matches!(decision.action, BotAction::SendMessage { .. })); } +#[test] +fn single_literal_match_reacts_once() { + let mut core = core_with_default(); + + let decision = core.decide_message(test_context(50), "oo"); + assert!(matches!(decision.action, BotAction::React { .. })); +} + #[test] fn duplicate_message_is_suppressed() { let mut core = core_with_default(); let first = core.decide_message(test_context(100), "oo"); - assert!(matches!(first.action, BotAction::React { .. } | BotAction::SendMessage { .. })); + assert!(matches!(first.action, BotAction::React { .. })); let second = core.decide_message(test_context(100), "oo"); assert_eq!(second.action, BotAction::Noop);