use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tracing::warn;
use chio_core::capability::{ChioScope, Constraint, SqlOperationClass, ToolGrant};
use chio_guards::{extract_action, ToolAction};
use chio_kernel::{GuardContext, KernelError, Verdict};
use thiserror::Error;
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum VectorGuardDenyReason {
#[error("tool action is not a vector-database access")]
NotAVectorAccess,
#[error("database '{database}' is not flagged as vector-shaped")]
NotVectorFlavored {
database: String,
},
#[error("collection '{collection}' is not in the allowlist")]
CollectionNotAllowed {
collection: String,
},
#[error("vector guard has no configured collection allowlist and allow_all is false")]
NoConfig,
#[error("namespace '{namespace}' is not in the allowlist")]
NamespaceNotAllowed {
namespace: String,
},
#[error("operation '{operation}' is not allowed by the active operation class")]
OperationNotAllowed {
operation: String,
},
#[error("top_k {requested} exceeds max_rows_returned {max}")]
TopKExceedsLimit {
requested: u64,
max: u64,
},
#[error("vector guard argument parse error: {error}")]
ParseError {
error: String,
},
}
impl VectorGuardDenyReason {
pub fn code(&self) -> &'static str {
match self {
Self::NotAVectorAccess => "not_a_vector_access",
Self::NotVectorFlavored { .. } => "not_vector_flavored",
Self::CollectionNotAllowed { .. } => "collection_not_allowed",
Self::NoConfig => "no_config",
Self::NamespaceNotAllowed { .. } => "namespace_not_allowed",
Self::OperationNotAllowed { .. } => "operation_not_allowed",
Self::TopKExceedsLimit { .. } => "top_k_exceeds_limit",
Self::ParseError { .. } => "parse_error",
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct VectorFieldPaths {
pub collection: Vec<String>,
pub namespace: Vec<String>,
pub operation: Vec<String>,
pub top_k: Vec<String>,
}
impl Default for VectorFieldPaths {
fn default() -> Self {
Self {
collection: vec![
"collection".into(),
"index".into(),
"class".into(),
"store".into(),
],
namespace: vec!["namespace".into(), "tenant".into(), "partition".into()],
operation: vec!["operation".into(), "op".into(), "action".into()],
top_k: vec!["top_k".into(), "topK".into(), "k".into(), "limit".into()],
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct VectorGuardConfig {
#[serde(default = "default_vendor_markers")]
pub vendor_markers: Vec<String>,
#[serde(default)]
pub collection_allowlist: Vec<String>,
#[serde(default)]
pub namespace_allowlist: Option<Vec<String>>,
#[serde(default)]
pub denied_operations: Vec<String>,
#[serde(default = "default_mutating_operations")]
pub mutating_operations: Vec<String>,
#[serde(default)]
pub field_paths: VectorFieldPaths,
#[serde(default)]
pub allow_all: bool,
}
fn default_vendor_markers() -> Vec<String> {
vec![
"vector".into(),
"pinecone".into(),
"weaviate".into(),
"qdrant".into(),
"chroma".into(),
"milvus".into(),
]
}
fn default_mutating_operations() -> Vec<String> {
vec![
"upsert".into(),
"insert".into(),
"update".into(),
"delete".into(),
"write".into(),
"index".into(),
"reindex".into(),
"drop".into(),
"drop_index".into(),
"create_collection".into(),
"delete_collection".into(),
]
}
impl Default for VectorGuardConfig {
fn default() -> Self {
Self {
vendor_markers: default_vendor_markers(),
collection_allowlist: Vec::new(),
namespace_allowlist: None,
denied_operations: Vec::new(),
mutating_operations: default_mutating_operations(),
field_paths: VectorFieldPaths::default(),
allow_all: false,
}
}
}
impl VectorGuardConfig {
pub fn is_empty(&self) -> bool {
self.collection_allowlist.is_empty()
&& self
.namespace_allowlist
.as_ref()
.map(|v| v.is_empty())
.unwrap_or(true)
&& self.denied_operations.is_empty()
}
pub fn collection_allowed(&self, name: &str) -> bool {
let lower = name.to_ascii_lowercase();
self.collection_allowlist
.iter()
.any(|c| c.to_ascii_lowercase() == lower)
}
pub fn namespace_allowed(&self, name: &str) -> bool {
match &self.namespace_allowlist {
None => true,
Some(list) => {
let lower = name.to_ascii_lowercase();
list.iter().any(|c| c.to_ascii_lowercase() == lower)
}
}
}
pub fn looks_like_vector(&self, database: &str, tool: &str) -> bool {
let db = database.to_ascii_lowercase();
let tl = tool.to_ascii_lowercase();
self.vendor_markers.iter().any(|m| {
!m.is_empty()
&& (db.contains(&m.to_ascii_lowercase()) || tl.contains(&m.to_ascii_lowercase()))
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VectorCall {
pub collection: String,
pub namespace: Option<String>,
pub operation: Option<String>,
pub top_k: Option<u64>,
}
pub struct VectorDbGuard {
config: VectorGuardConfig,
}
impl VectorDbGuard {
pub fn new(config: VectorGuardConfig) -> Self {
if config.allow_all {
warn!(
target: "chio.data-guards.vector",
"vector-db-guard constructed with allow_all=true; fail-closed default disabled"
);
}
Self { config }
}
pub fn config(&self) -> &VectorGuardConfig {
&self.config
}
pub fn check(&self, call: &VectorCall, scope: &ChioScope) -> Result<(), VectorGuardDenyReason> {
self.check_with_matched_grant(call, scope, None)
}
fn check_with_matched_grant(
&self,
call: &VectorCall,
scope: &ChioScope,
matched_grant_index: Option<usize>,
) -> Result<(), VectorGuardDenyReason> {
if self.config.allow_all {
return Ok(());
}
if self.config.is_empty() {
return Err(VectorGuardDenyReason::NoConfig);
}
if !self.config.collection_allowlist.is_empty()
&& !self.config.collection_allowed(&call.collection)
{
return Err(VectorGuardDenyReason::CollectionNotAllowed {
collection: call.collection.clone(),
});
}
if let Some(ns) = &call.namespace {
if !self.config.namespace_allowed(ns) {
return Err(VectorGuardDenyReason::NamespaceNotAllowed {
namespace: ns.clone(),
});
}
} else if self
.config
.namespace_allowlist
.as_ref()
.map(|v| !v.is_empty())
.unwrap_or(false)
{
return Err(VectorGuardDenyReason::NamespaceNotAllowed {
namespace: String::new(),
});
}
if let Some(op) = &call.operation {
let op_lower = op.to_ascii_lowercase();
if self
.config
.denied_operations
.iter()
.any(|d| d.to_ascii_lowercase() == op_lower)
{
return Err(VectorGuardDenyReason::OperationNotAllowed {
operation: op.clone(),
});
}
let class = operation_class_for_request(scope, matched_grant_index);
if let Some(class) = class {
let is_mutation = self
.config
.mutating_operations
.iter()
.any(|m| m.to_ascii_lowercase() == op_lower);
match (class, is_mutation) {
(SqlOperationClass::ReadOnly, true) => {
return Err(VectorGuardDenyReason::OperationNotAllowed {
operation: op.clone(),
})
}
(SqlOperationClass::ReadWrite, _) if op_lower == "drop_index" => {
return Err(VectorGuardDenyReason::OperationNotAllowed {
operation: op.clone(),
});
}
_ => {}
}
}
} else if let Some(class) = operation_class_for_request(scope, matched_grant_index) {
if matches!(
class,
SqlOperationClass::ReadOnly | SqlOperationClass::ReadWrite
) {
return Err(VectorGuardDenyReason::OperationNotAllowed {
operation: String::new(),
});
}
}
if let Some(max) = max_rows_for_request(scope, matched_grant_index) {
match call.top_k {
Some(k) if k > max => {
return Err(VectorGuardDenyReason::TopKExceedsLimit { requested: k, max });
}
None => {
return Err(VectorGuardDenyReason::TopKExceedsLimit {
requested: u64::MAX,
max,
});
}
_ => {}
}
}
Ok(())
}
pub fn extract_call(&self, arguments: &Value) -> Result<VectorCall, VectorGuardDenyReason> {
if !arguments.is_object() && !arguments.is_null() {
return Err(VectorGuardDenyReason::ParseError {
error: "arguments must be a JSON object".into(),
});
}
let collection = pick_string(arguments, &self.config.field_paths.collection)
.map(|s| s.to_ascii_lowercase())
.ok_or(VectorGuardDenyReason::ParseError {
error: "missing collection/index field".into(),
})?;
let namespace = pick_string(arguments, &self.config.field_paths.namespace);
let operation = pick_string(arguments, &self.config.field_paths.operation);
let top_k = pick_number(arguments, &self.config.field_paths.top_k);
Ok(VectorCall {
collection,
namespace,
operation,
top_k,
})
}
}
impl chio_kernel::Guard for VectorDbGuard {
fn name(&self) -> &str {
"vector-db"
}
fn evaluate(&self, ctx: &GuardContext) -> Result<Verdict, KernelError> {
let tool = &ctx.request.tool_name;
let args = &ctx.request.arguments;
let action = extract_action(tool, args);
let database = match &action {
ToolAction::DatabaseQuery { database, .. } => database.clone(),
ToolAction::MemoryRead { store, .. } | ToolAction::MemoryWrite { store, .. } => {
store.clone()
}
_ => tool.clone(),
};
if !self.config.looks_like_vector(&database, tool) {
return Ok(Verdict::Allow);
}
let call = match self.extract_call(args) {
Ok(c) => c,
Err(reason) => {
warn!(
target: "chio.data-guards.vector",
code = reason.code(),
reason = %reason,
database = %database,
"vector-db-guard denied: parse failed"
);
return Ok(Verdict::Deny);
}
};
if self.config.allow_all {
return Ok(Verdict::Allow);
}
match self.check_with_matched_grant(&call, ctx.scope, ctx.matched_grant_index) {
Ok(()) => Ok(Verdict::Allow),
Err(reason) => {
warn!(
target: "chio.data-guards.vector",
code = reason.code(),
reason = %reason,
database = %database,
collection = %call.collection,
"vector-db-guard denied"
);
Ok(Verdict::Deny)
}
}
}
}
fn active_grant(scope: &ChioScope, matched_grant_index: Option<usize>) -> Option<&ToolGrant> {
matched_grant_index.and_then(|index| scope.grants.get(index))
}
fn operation_class_for_constraints(constraints: &[Constraint]) -> Option<SqlOperationClass> {
let mut strongest: Option<SqlOperationClass> = None;
for c in constraints {
if let Constraint::OperationClass(class) = c {
strongest = Some(match (strongest, *class) {
(None, new) => new,
(Some(SqlOperationClass::ReadOnly), _) => SqlOperationClass::ReadOnly,
(_, SqlOperationClass::ReadOnly) => SqlOperationClass::ReadOnly,
(Some(SqlOperationClass::ReadWrite), _) => SqlOperationClass::ReadWrite,
(_, SqlOperationClass::ReadWrite) => SqlOperationClass::ReadWrite,
(Some(SqlOperationClass::Admin), SqlOperationClass::Admin) => {
SqlOperationClass::Admin
}
});
}
}
strongest
}
fn operation_class_for_request(
scope: &ChioScope,
matched_grant_index: Option<usize>,
) -> Option<SqlOperationClass> {
if let Some(grant) = active_grant(scope, matched_grant_index) {
return operation_class_for_constraints(&grant.constraints);
}
let mut strongest: Option<SqlOperationClass> = None;
for grant in &scope.grants {
strongest = match (
strongest,
operation_class_for_constraints(&grant.constraints),
) {
(Some(SqlOperationClass::ReadOnly), _) => Some(SqlOperationClass::ReadOnly),
(_, Some(SqlOperationClass::ReadOnly)) => Some(SqlOperationClass::ReadOnly),
(Some(SqlOperationClass::ReadWrite), _) => Some(SqlOperationClass::ReadWrite),
(_, Some(SqlOperationClass::ReadWrite)) => Some(SqlOperationClass::ReadWrite),
(None, Some(class)) => Some(class),
(current, None) => current,
(Some(SqlOperationClass::Admin), Some(SqlOperationClass::Admin)) => {
Some(SqlOperationClass::Admin)
}
};
}
strongest
}
fn max_rows_for_constraints(constraints: &[Constraint]) -> Option<u64> {
let mut min: Option<u64> = None;
for c in constraints {
if let Constraint::MaxRowsReturned(n) = c {
min = Some(min.map_or(*n, |m| m.min(*n)));
}
}
min
}
fn max_rows_for_request(scope: &ChioScope, matched_grant_index: Option<usize>) -> Option<u64> {
if let Some(grant) = active_grant(scope, matched_grant_index) {
return max_rows_for_constraints(&grant.constraints);
}
let mut min: Option<u64> = None;
for grant in &scope.grants {
if let Some(grant_min) = max_rows_for_constraints(&grant.constraints) {
min = Some(min.map_or(grant_min, |current| current.min(grant_min)));
}
}
min
}
fn pick_string(value: &Value, keys: &[String]) -> Option<String> {
for key in keys {
if let Some(s) = value.get(key).and_then(|v| v.as_str()) {
if !s.is_empty() {
return Some(s.to_string());
}
}
}
None
}
fn pick_number(value: &Value, keys: &[String]) -> Option<u64> {
for key in keys {
if let Some(n) = value.get(key).and_then(|v| v.as_u64()) {
return Some(n);
}
if let Some(s) = value.get(key).and_then(|v| v.as_str()) {
if let Ok(n) = s.parse::<u64>() {
return Some(n);
}
}
}
None
}
#[doc(hidden)]
pub fn lowercase_set<I, S>(items: I) -> HashSet<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
items
.into_iter()
.map(|s| s.as_ref().to_ascii_lowercase())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use chio_core::capability::{CapabilityToken, CapabilityTokenBody, Operation, ToolGrant};
use chio_core::crypto::Keypair;
use chio_kernel::{Guard, GuardContext, ToolCallRequest, Verdict};
fn grant_with_constraints(constraints: Vec<Constraint>) -> ToolGrant {
ToolGrant {
server_id: "srv".into(),
tool_name: "*".into(),
operations: vec![Operation::Invoke],
constraints,
max_invocations: None,
max_cost_per_invocation: None,
max_total_cost: None,
dpop_required: None,
}
}
fn scope_with(constraints: Vec<Constraint>) -> ChioScope {
ChioScope {
grants: vec![grant_with_constraints(constraints)],
resource_grants: vec![],
prompt_grants: vec![],
}
}
fn test_capability() -> CapabilityToken {
let kp = Keypair::generate();
CapabilityToken::sign(
CapabilityTokenBody {
id: "cap-vector-guard".into(),
issuer: kp.public_key(),
subject: kp.public_key(),
scope: ChioScope::default(),
issued_at: 0,
expires_at: u64::MAX,
delegation_chain: vec![],
},
&kp,
)
.unwrap()
}
fn base_cfg() -> VectorGuardConfig {
VectorGuardConfig {
collection_allowlist: vec!["docs".into()],
..Default::default()
}
}
#[test]
fn deny_collection_not_in_allowlist() {
let g = VectorDbGuard::new(base_cfg());
let call = VectorCall {
collection: "secrets".into(),
namespace: None,
operation: Some("query".into()),
top_k: Some(10),
};
let err = g.check(&call, &ChioScope::default()).unwrap_err();
assert!(matches!(
err,
VectorGuardDenyReason::CollectionNotAllowed { .. }
));
}
#[test]
fn allow_collection_in_allowlist() {
let g = VectorDbGuard::new(base_cfg());
let call = VectorCall {
collection: "docs".into(),
namespace: None,
operation: Some("query".into()),
top_k: Some(5),
};
g.check(&call, &ChioScope::default()).unwrap();
}
#[test]
fn deny_cross_namespace() {
let cfg = VectorGuardConfig {
collection_allowlist: vec!["docs".into()],
namespace_allowlist: Some(vec!["tenant-a".into()]),
..Default::default()
};
let g = VectorDbGuard::new(cfg);
let call = VectorCall {
collection: "docs".into(),
namespace: Some("tenant-b".into()),
operation: None,
top_k: None,
};
let err = g.check(&call, &ChioScope::default()).unwrap_err();
assert!(matches!(
err,
VectorGuardDenyReason::NamespaceNotAllowed { .. }
));
}
#[test]
fn deny_upsert_under_readonly() {
let g = VectorDbGuard::new(base_cfg());
let call = VectorCall {
collection: "docs".into(),
namespace: None,
operation: Some("upsert".into()),
top_k: None,
};
let scope = scope_with(vec![Constraint::OperationClass(
SqlOperationClass::ReadOnly,
)]);
let err = g.check(&call, &scope).unwrap_err();
assert!(matches!(
err,
VectorGuardDenyReason::OperationNotAllowed { .. }
));
}
#[test]
fn allow_query_under_readonly() {
let g = VectorDbGuard::new(base_cfg());
let call = VectorCall {
collection: "docs".into(),
namespace: None,
operation: Some("query".into()),
top_k: Some(1),
};
let scope = scope_with(vec![
Constraint::OperationClass(SqlOperationClass::ReadOnly),
Constraint::MaxRowsReturned(50),
]);
g.check(&call, &scope).unwrap();
}
#[test]
fn deny_top_k_over_max_rows() {
let g = VectorDbGuard::new(base_cfg());
let call = VectorCall {
collection: "docs".into(),
namespace: None,
operation: Some("query".into()),
top_k: Some(500),
};
let scope = scope_with(vec![Constraint::MaxRowsReturned(50)]);
let err = g.check(&call, &scope).unwrap_err();
match err {
VectorGuardDenyReason::TopKExceedsLimit { requested, max } => {
assert_eq!(requested, 500);
assert_eq!(max, 50);
}
other => panic!("unexpected reason: {other:?}"),
}
}
#[test]
fn deny_missing_top_k_when_ceiling_set() {
let g = VectorDbGuard::new(base_cfg());
let call = VectorCall {
collection: "docs".into(),
namespace: None,
operation: Some("query".into()),
top_k: None,
};
let scope = scope_with(vec![Constraint::MaxRowsReturned(50)]);
let err = g.check(&call, &scope).unwrap_err();
assert!(matches!(
err,
VectorGuardDenyReason::TopKExceedsLimit { .. }
));
}
#[test]
fn empty_config_denies() {
let g = VectorDbGuard::new(VectorGuardConfig::default());
let call = VectorCall {
collection: "docs".into(),
namespace: None,
operation: None,
top_k: None,
};
let err = g.check(&call, &ChioScope::default()).unwrap_err();
assert!(matches!(err, VectorGuardDenyReason::NoConfig));
}
#[test]
fn allow_all_skips_allowlists() {
let g = VectorDbGuard::new(VectorGuardConfig {
allow_all: true,
..Default::default()
});
let call = VectorCall {
collection: "anything".into(),
namespace: Some("anywhere".into()),
operation: Some("upsert".into()),
top_k: Some(10_000),
};
g.check(&call, &ChioScope::default()).unwrap();
}
#[test]
fn allow_all_still_denies_parse_errors() {
let guard = VectorDbGuard::new(VectorGuardConfig {
allow_all: true,
..Default::default()
});
let request = ToolCallRequest {
request_id: "req-vector-allow-all-parse".to_string(),
capability: test_capability(),
tool_name: "pinecone_query".to_string(),
server_id: "srv".to_string(),
agent_id: "agent".to_string(),
arguments: serde_json::json!({"namespace": "tenant-a"}),
dpop_proof: None,
governed_intent: None,
approval_token: None,
model_metadata: None,
federated_origin_kernel_id: None,
};
let scope = ChioScope::default();
let agent_id = String::from("agent");
let server_id = String::from("srv");
let verdict = guard
.evaluate(&GuardContext {
request: &request,
scope: &scope,
agent_id: &agent_id,
server_id: &server_id,
session_filesystem_roots: None,
matched_grant_index: None,
})
.unwrap();
assert_eq!(verdict, Verdict::Deny);
}
#[test]
fn extract_call_parses_defaults() {
let g = VectorDbGuard::new(base_cfg());
let args = serde_json::json!({
"collection": "docs",
"namespace": "tenant-a",
"operation": "query",
"top_k": 42
});
let call = g.extract_call(&args).unwrap();
assert_eq!(call.collection, "docs");
assert_eq!(call.namespace.as_deref(), Some("tenant-a"));
assert_eq!(call.operation.as_deref(), Some("query"));
assert_eq!(call.top_k, Some(42));
}
#[test]
fn extract_call_missing_collection_errors() {
let g = VectorDbGuard::new(base_cfg());
let args = serde_json::json!({"namespace": "tenant-a"});
let err = g.extract_call(&args).unwrap_err();
assert!(matches!(err, VectorGuardDenyReason::ParseError { .. }));
}
#[test]
fn looks_like_vector_matches_vendor_substring() {
let cfg = VectorGuardConfig::default();
assert!(cfg.looks_like_vector("pinecone-prod", "query"));
assert!(cfg.looks_like_vector("main", "weaviate_search"));
assert!(cfg.looks_like_vector("vector-store", "query"));
assert!(!cfg.looks_like_vector("postgres", "sql"));
}
#[test]
fn reason_codes_are_stable() {
assert_eq!(VectorGuardDenyReason::NoConfig.code(), "no_config");
assert_eq!(
VectorGuardDenyReason::CollectionNotAllowed {
collection: "x".into(),
}
.code(),
"collection_not_allowed"
);
assert_eq!(
VectorGuardDenyReason::TopKExceedsLimit {
requested: 1,
max: 0,
}
.code(),
"top_k_exceeds_limit"
);
}
#[test]
fn lowercase_set_normalises() {
let s = lowercase_set(["Foo", "BAR"]);
assert!(s.contains("foo"));
assert!(s.contains("bar"));
}
}