Files
ruvnet--RuView/v2/crates/homecore-assist/src/embedding.rs
T
rUv 9b126e927e harden(assist security): bound untrusted utterance (DoS); cmd-injection/ReDoS/NaN/fail-open all proven clean with evidence (#1086)
* 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>
2026-06-14 21:34:38 -04:00

198 lines
7.2 KiB
Rust

//! Deterministic text embedding for semantic intent matching.
//!
//! No ML model dependency: utterances are embedded with the classic
//! **feature-hashing** (hashing-vectorizer) technique. Each n-gram feature is
//! hashed into a fixed-width vector; a second sign-hash decides whether the
//! feature adds or subtracts, which keeps the expected dot-product unbiased
//! under collisions. The vector is L2-normalised so that cosine similarity is
//! a clean `1 - distance`.
//!
//! Features used per utterance:
//! - **word unigrams** — whole tokens after lowercasing/trimming punctuation.
//! - **character trigrams** — sliding 3-grams over each padded token, which
//! gives partial-overlap credit ("kitchen" ~ "kitchens") and robustness to
//! small lexical variation.
//!
//! This is intentionally *lexical-semantic*: paraphrases that share tokens
//! ("turn on the light" vs "turn on the kitchen light") land close together,
//! while unrelated utterances ("play jazz music") land far apart. It is a real,
//! reproducible similarity signal — not a hash that ignores meaning.
//!
//! The output dimension matches [`EMBEDDING_DIM`] and is consumed directly by
//! the exact in-memory cosine k-NN in `crate::semantic_recognizer`.
/// Dimensionality of the hashed embedding space.
///
/// 256 buckets keeps collisions low for the small intent vocabularies HOMECORE
/// deals with while staying cheap to index in HNSW.
pub const EMBEDDING_DIM: usize = 256;
// FNV-1a 64 constants — small, fast, well-distributed for feature hashing.
const FNV_OFFSET_BASIS_64: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME_64: u64 = 0x0000_0100_0000_01b3;
#[inline]
fn fnv1a64(seed: u64, bytes: &[u8]) -> u64 {
let mut hash = seed;
for &b in bytes {
hash ^= u64::from(b);
hash = hash.wrapping_mul(FNV_PRIME_64);
}
hash
}
/// Accumulate one hashed feature into `acc` with signed weight.
#[inline]
fn add_feature(acc: &mut [f32], feature: &[u8], weight: f32) {
let h = fnv1a64(FNV_OFFSET_BASIS_64, feature);
let bucket = (h % EMBEDDING_DIM as u64) as usize;
// Independent sign hash (different seed) → unbiased under collisions.
let sign = if fnv1a64(0x100, feature) & 1 == 0 { 1.0 } else { -1.0 };
acc[bucket] += sign * weight;
}
/// Normalise text: lowercase, keep alphanumerics, split on everything else.
fn tokenize(text: &str) -> Vec<String> {
text.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|s| !s.is_empty())
.map(|s| s.to_owned())
.collect()
}
/// Embed an utterance into a deterministic, L2-normalised vector.
///
/// Returns a zero vector only for input with no alphanumeric content.
pub fn embed(text: &str) -> Vec<f32> {
let mut acc = vec![0.0_f32; EMBEDDING_DIM];
let tokens = tokenize(text);
for tok in &tokens {
// Word unigram — weighted higher than sub-word features.
add_feature(&mut acc, format!("w:{tok}").as_bytes(), 1.5);
// Character trigrams over a padded token so prefixes/suffixes count.
let padded: Vec<char> = format!("^{tok}$").chars().collect();
if padded.len() >= 3 {
for window in padded.windows(3) {
let gram: String = window.iter().collect();
add_feature(&mut acc, format!("c:{gram}").as_bytes(), 1.0);
}
}
}
l2_normalise(&mut acc);
acc
}
/// L2-normalise in place; no-op for the zero vector.
fn l2_normalise(v: &mut [f32]) {
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 1e-12 {
for x in v.iter_mut() {
*x /= norm;
}
}
}
/// Cosine similarity of two equal-length vectors (dot product of unit vectors).
///
/// Exposed for tests and for callers that want similarity without round-tripping
/// through the HNSW index.
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
debug_assert_eq!(a.len(), b.len());
a.iter().zip(b).map(|(x, y)| x * y).sum()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedding_has_correct_dim() {
assert_eq!(embed("turn on the light").len(), EMBEDDING_DIM);
}
#[test]
fn embedding_is_deterministic() {
assert_eq!(embed("turn on the light"), embed("turn on the light"));
}
#[test]
fn embedding_is_unit_norm() {
let v = embed("turn on the kitchen light");
let norm_sq: f32 = v.iter().map(|x| x * x).sum();
assert!((norm_sq - 1.0).abs() < 1e-4, "norm^2 = {norm_sq}");
}
#[test]
fn empty_input_is_zero_vector() {
let v = embed("!!! ???");
assert!(v.iter().all(|x| *x == 0.0));
}
#[test]
fn paraphrase_is_more_similar_than_unrelated() {
let exemplar = embed("turn on the light");
let paraphrase = embed("turn on the kitchen light");
let unrelated = embed("play some jazz music");
let sim_para = cosine_similarity(&exemplar, &paraphrase);
let sim_unrel = cosine_similarity(&exemplar, &unrelated);
assert!(
sim_para > sim_unrel,
"paraphrase ({sim_para:.3}) must beat unrelated ({sim_unrel:.3})"
);
// Real, non-trivial separation.
assert!(sim_para > 0.5, "paraphrase similarity too low: {sim_para:.3}");
assert!(sim_unrel < 0.3, "unrelated similarity too high: {sim_unrel:.3}");
}
#[test]
fn embeddings_are_structurally_finite() {
// SECURITY (NaN-poisoning): the embedding path takes only `&str` and
// produces values via FNV feature-hashing + a guarded L2 normalise.
// There is NO external float input and NO unguarded division, so a
// crafted utterance cannot inject NaN/±Inf into a vector and poison the
// cosine k-NN match. Prove every component is finite across adversarial
// inputs (empty, punctuation-only, unicode, very long, control chars).
for s in [
"",
"!!! ???",
"turn on the kitchen light",
"🔥🔥🔥 \u{0}\u{1}\u{7f} mix",
&"x".repeat(10_000),
"NaN inf -inf 1e999",
] {
let v = embed(s);
assert_eq!(v.len(), EMBEDDING_DIM);
assert!(
v.iter().all(|x| x.is_finite()),
"embedding of {s:?} contained a non-finite component"
);
}
}
#[test]
fn cosine_with_zero_vector_is_finite_not_nan() {
// SECURITY (NaN-poisoning): an empty/punctuation-only utterance embeds
// to the zero vector. Cosine against any exemplar must be a finite 0.0,
// never NaN — so a below-threshold comparison stays well-defined and the
// recognizer falls through (no action) rather than matching on garbage.
let zero = embed("!!! ???");
let real = embed("turn on the light");
let sim = cosine_similarity(&zero, &real);
assert!(sim.is_finite(), "cosine vs zero vector must be finite, got {sim}");
assert_eq!(sim, 0.0, "dot product with the zero vector is exactly 0");
}
#[test]
fn identical_text_is_similarity_one() {
let a = embed("lock the front door");
let b = embed("lock the front door");
let sim = cosine_similarity(&a, &b);
assert!((sim - 1.0).abs() < 1e-4, "sim = {sim}");
}
}