use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use nexo_broker::{AnyBroker, BrokerHandle};
use nexo_llm::ToolDef;
use serde::Serialize;
use crate::agent::extension_tool::{ExtensionTool, EXT_NAME_PREFIX};
use crate::agent::tool_registry::{ToolHandler, ToolMeta, ToolRegistry};
const BROKER_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
pub const RESERVED_PREFIXES: &[&str] = &["agent_", "system_", "nexo_", "mcp_", "ext_"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NamespaceEnforcement {
Warn,
Strict,
}
impl NamespaceEnforcement {
pub fn from_env() -> Self {
match std::env::var("NEXO_PLUGIN_NAMESPACE_STRICT").as_deref() {
Ok("1") | Ok("true") => Self::Strict,
_ => Self::Warn,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Warn => "warn",
Self::Strict => "strict",
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct NamespaceViolation {
pub plugin_id: String,
pub attempted_name: String,
pub reason: NamespaceViolationReason,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(tag = "kind", content = "detail")]
pub enum NamespaceViolationReason {
ReservedPrefix(&'static str),
OutOfNamespace,
NotInExpose,
Collision,
}
impl NamespaceViolationReason {
pub fn as_str(&self) -> &'static str {
match self {
Self::ReservedPrefix(_) => "ReservedPrefix",
Self::OutOfNamespace => "OutOfNamespace",
Self::NotInExpose => "NotInExpose",
Self::Collision => "Collision",
}
}
pub fn reserved_prefix(&self) -> Option<&'static str> {
match self {
Self::ReservedPrefix(p) => Some(*p),
_ => None,
}
}
}
pub struct ScopedToolRegistry {
plugin_id: String,
allowed_canonical: HashSet<String>,
inner: Arc<ToolRegistry>,
violations: Mutex<Vec<NamespaceViolation>>,
mode: NamespaceEnforcement,
broker: Option<AnyBroker>,
}
impl ScopedToolRegistry {
pub fn new(
plugin_id: String,
manifest_expose: &[String],
inner: Arc<ToolRegistry>,
mode: NamespaceEnforcement,
broker: Option<AnyBroker>,
) -> Self {
let mut allowed_canonical = HashSet::with_capacity(manifest_expose.len() * 2);
for raw in manifest_expose {
allowed_canonical.insert(raw.clone());
allowed_canonical.insert(ExtensionTool::prefixed_name(&plugin_id, raw));
}
Self {
plugin_id,
allowed_canonical,
inner,
violations: Mutex::new(Vec::new()),
mode,
broker,
}
}
pub fn mode(&self) -> NamespaceEnforcement {
self.mode
}
pub fn plugin_id(&self) -> &str {
&self.plugin_id
}
pub fn register(
&self,
def: ToolDef,
handler: impl ToolHandler + 'static,
) -> Result<(), NamespaceViolation> {
self.register_arc(def, Arc::new(handler))
}
pub fn register_arc(
&self,
def: ToolDef,
handler: Arc<dyn ToolHandler>,
) -> Result<(), NamespaceViolation> {
match self.validate_name(&def.name) {
Ok(()) => self.commit_register(def, handler, None),
Err(reason) => self.handle_violation(def, handler, reason, None),
}
}
pub fn register_with_meta(
&self,
def: ToolDef,
handler: impl ToolHandler + 'static,
meta: ToolMeta,
) -> Result<(), NamespaceViolation> {
let arc: Arc<dyn ToolHandler> = Arc::new(handler);
match self.validate_name(&def.name) {
Ok(()) => self.commit_register(def, arc, Some(meta)),
Err(reason) => self.handle_violation(def, arc, reason, Some(meta)),
}
}
pub fn drain_violations(&self) -> Vec<NamespaceViolation> {
let mut guard = self.violations.lock().expect("violations mutex poisoned");
std::mem::take(&mut *guard)
}
fn validate_name(&self, name: &str) -> Result<(), NamespaceViolationReason> {
let canonical_prefix = format!("{}{}_", EXT_NAME_PREFIX, self.plugin_id);
for prefix in RESERVED_PREFIXES {
if name.starts_with(prefix) {
if *prefix == EXT_NAME_PREFIX && name.starts_with(&canonical_prefix) {
break;
}
return Err(NamespaceViolationReason::ReservedPrefix(prefix));
}
}
let bare_prefix = format!("{}_", self.plugin_id);
if !name.starts_with(&bare_prefix) && !name.starts_with(&canonical_prefix) {
return Err(NamespaceViolationReason::OutOfNamespace);
}
if !self.allowed_canonical.contains(name) {
return Err(NamespaceViolationReason::NotInExpose);
}
Ok(())
}
fn commit_register(
&self,
def: ToolDef,
handler: Arc<dyn ToolHandler>,
meta: Option<ToolMeta>,
) -> Result<(), NamespaceViolation> {
let name = def.name.clone();
let inserted = self.inner.register_if_absent_arc(def, handler);
if !inserted {
let v = NamespaceViolation {
plugin_id: self.plugin_id.clone(),
attempted_name: name,
reason: NamespaceViolationReason::Collision,
};
self.record_and_emit(&v, true);
return Err(v);
}
if let Some(m) = meta {
self.inner.set_meta(&name, m);
}
Ok(())
}
fn handle_violation(
&self,
def: ToolDef,
handler: Arc<dyn ToolHandler>,
reason: NamespaceViolationReason,
meta: Option<ToolMeta>,
) -> Result<(), NamespaceViolation> {
let v = NamespaceViolation {
plugin_id: self.plugin_id.clone(),
attempted_name: def.name.clone(),
reason,
};
match self.mode {
NamespaceEnforcement::Strict => {
self.record_and_emit(&v, true);
Err(v)
}
NamespaceEnforcement::Warn => {
self.record_and_emit(&v, false);
let inserted = self.inner.register_if_absent_arc(def, handler);
if !inserted {
let collision = NamespaceViolation {
plugin_id: self.plugin_id.clone(),
attempted_name: v.attempted_name.clone(),
reason: NamespaceViolationReason::Collision,
};
self.record_and_emit(&collision, true);
return Err(collision);
}
if let Some(m) = meta {
self.inner.set_meta(&v.attempted_name, m);
}
Ok(())
}
}
}
fn record_and_emit(&self, v: &NamespaceViolation, rejected: bool) {
tracing::warn!(
plugin_id = %v.plugin_id,
tool = %v.attempted_name,
reason = %v.reason.as_str(),
mode = %self.mode.as_str(),
rejected,
"tool namespace violation",
);
if let Ok(mut guard) = self.violations.lock() {
guard.push(v.clone());
}
if let Some(broker) = self.broker.clone() {
let plugin_id = v.plugin_id.clone();
let attempted_name = v.attempted_name.clone();
let reason = v.reason;
let mode = self.mode.as_str();
tokio::spawn(async move {
emit_violation_event(broker, plugin_id, attempted_name, reason, mode, rejected)
.await;
});
}
}
}
async fn emit_violation_event(
broker: AnyBroker,
plugin_id: String,
attempted_name: String,
reason: NamespaceViolationReason,
mode: &'static str,
rejected: bool,
) {
let topic = format!("plugin.lifecycle.{plugin_id}.namespace_violation");
let mut payload = serde_json::json!({
"plugin_id": plugin_id,
"attempted_name": attempted_name,
"reason": reason.as_str(),
"mode": mode,
"rejected": rejected,
});
if let Some(rp) = reason.reserved_prefix() {
payload["reserved_prefix"] = serde_json::Value::String(rp.to_string());
}
let bytes = match serde_json::to_vec(&payload) {
Ok(b) => b,
Err(e) => {
tracing::warn!(error = %e, "namespace_violation payload serialize failed");
return;
}
};
let event = nexo_broker::Event::new(&topic, "plugin.namespace", payload);
let publish = async move {
if let Err(e) = broker.publish(&topic, event).await {
tracing::warn!(error = %e, "namespace_violation publish failed");
}
};
if tokio::time::timeout(BROKER_CONNECT_TIMEOUT, publish)
.await
.is_err()
{
tracing::warn!("namespace_violation publish timed out");
}
let _ = bytes; }
#[cfg(test)]
mod tests {
use super::*;
use nexo_llm::ToolDef;
struct DummyHandler;
#[async_trait::async_trait]
impl ToolHandler for DummyHandler {
async fn call(
&self,
_ctx: &crate::agent::context::AgentContext,
_args: serde_json::Value,
) -> anyhow::Result<serde_json::Value> {
Ok(serde_json::Value::Null)
}
}
fn def(name: &str) -> ToolDef {
ToolDef {
name: name.to_string(),
description: "x".into(),
parameters: serde_json::json!({}),
}
}
fn build_scoped(
plugin_id: &str,
expose: &[&str],
mode: NamespaceEnforcement,
) -> (Arc<ScopedToolRegistry>, Arc<ToolRegistry>) {
let inner = Arc::new(ToolRegistry::new());
let exp: Vec<String> = expose.iter().map(|s| s.to_string()).collect();
let scoped = Arc::new(ScopedToolRegistry::new(
plugin_id.to_string(),
&exp,
inner.clone(),
mode,
None,
));
(scoped, inner)
}
#[test]
fn register_accepts_canonical_ext_name() {
let (scoped, inner) = build_scoped("slack", &["slack_send"], NamespaceEnforcement::Strict);
let result = scoped.register(def("ext_slack_slack_send"), DummyHandler);
assert!(result.is_ok());
assert!(inner.contains("ext_slack_slack_send"));
assert!(scoped.drain_violations().is_empty());
}
#[test]
fn register_accepts_bare_plugin_id_prefix() {
let (scoped, inner) = build_scoped("slack", &["slack_send"], NamespaceEnforcement::Strict);
let result = scoped.register(def("slack_send"), DummyHandler);
assert!(result.is_ok());
assert!(inner.contains("slack_send"));
}
#[test]
fn register_rejects_unprefixed_tool() {
let (scoped, _) = build_scoped("slack", &["slack_send"], NamespaceEnforcement::Strict);
let err = scoped
.register(def("send_message"), DummyHandler)
.unwrap_err();
assert_eq!(err.reason, NamespaceViolationReason::OutOfNamespace);
}
#[test]
fn register_rejects_reserved_prefix_agent() {
let (scoped, _) = build_scoped("slack", &["slack_send"], NamespaceEnforcement::Strict);
let err = scoped
.register(def("agent_route"), DummyHandler)
.unwrap_err();
assert!(matches!(
err.reason,
NamespaceViolationReason::ReservedPrefix("agent_")
));
}
#[test]
fn register_rejects_reserved_prefix_mcp() {
let (scoped, _) = build_scoped("slack", &["slack_send"], NamespaceEnforcement::Strict);
let err = scoped.register(def("mcp_call"), DummyHandler).unwrap_err();
assert!(matches!(
err.reason,
NamespaceViolationReason::ReservedPrefix("mcp_")
));
}
#[test]
fn register_rejects_collision_first_wins_in_warn_mode() {
let (scoped, inner) = build_scoped("slack", &["slack_send"], NamespaceEnforcement::Warn);
inner.register(def("slack_send"), DummyHandler);
let err = scoped
.register(def("slack_send"), DummyHandler)
.unwrap_err();
assert_eq!(err.reason, NamespaceViolationReason::Collision);
}
#[test]
fn register_rejects_tool_not_in_manifest_expose() {
let (scoped, _) = build_scoped("slack", &["slack_send"], NamespaceEnforcement::Strict);
let err = scoped
.register(def("slack_unknown"), DummyHandler)
.unwrap_err();
assert_eq!(err.reason, NamespaceViolationReason::NotInExpose);
}
#[test]
fn strict_mode_rejects_warn_mode_records_and_continues() {
let (scoped_strict, inner_strict) =
build_scoped("slack", &["slack_send"], NamespaceEnforcement::Strict);
let err = scoped_strict
.register(def("not_in_expose_at_all"), DummyHandler)
.unwrap_err();
assert_eq!(err.reason, NamespaceViolationReason::OutOfNamespace);
assert!(!inner_strict.contains("not_in_expose_at_all"));
let violations_strict = scoped_strict.drain_violations();
assert_eq!(violations_strict.len(), 1);
let (scoped_warn, inner_warn) =
build_scoped("slack", &["slack_send"], NamespaceEnforcement::Warn);
let result = scoped_warn.register(def("slack_unknown_tool"), DummyHandler);
assert!(result.is_ok());
assert!(inner_warn.contains("slack_unknown_tool"));
let violations_warn = scoped_warn.drain_violations();
assert_eq!(violations_warn.len(), 1);
assert_eq!(
violations_warn[0].reason,
NamespaceViolationReason::NotInExpose
);
}
#[test]
fn empty_expose_rejects_every_register() {
let (scoped, _) = build_scoped("slack", &[], NamespaceEnforcement::Strict);
let err = scoped
.register(def("slack_send"), DummyHandler)
.unwrap_err();
assert_eq!(err.reason, NamespaceViolationReason::NotInExpose);
}
#[test]
fn drain_violations_consumes_buffer() {
let (scoped, _) = build_scoped("slack", &[], NamespaceEnforcement::Strict);
let _ = scoped.register(def("slack_a"), DummyHandler);
let _ = scoped.register(def("slack_b"), DummyHandler);
assert_eq!(scoped.drain_violations().len(), 2);
assert!(scoped.drain_violations().is_empty());
}
#[test]
fn reserved_prefix_takes_precedence_over_out_of_namespace() {
let (scoped, _) = build_scoped("agentplugin", &[], NamespaceEnforcement::Strict);
let err = scoped
.register(def("agent_route"), DummyHandler)
.unwrap_err();
assert!(matches!(
err.reason,
NamespaceViolationReason::ReservedPrefix("agent_")
));
}
}