use libremetaverse_types::UUID; use libremetaverse_types::compat::CancellationToken; use metacrate_grid_agent::{ ActionOrigin, AllowedOrigins, AuthorizedAction, AuthorizedToolBackend, BackendError, BackendFuture, BoundedText, Capability, FixedCost, Idempotency, MemoryPolicyAudit, OriginClass, PolicyAuditSink, PolicyDisposition, PolicyFinalOutcome, PolicyGateway, PolicyLimits, PolicyRequestContext, PolicyTool, PolicyToolExecutor, ProposedToolCall, ResourceCost, Risk, ToolCallOutcome, ToolDefinition, ToolExecution, ToolExecutor, ToolSchema, }; use serde_json::{Value, json}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; fn id(text: &str) -> UUID { UUID::new_with_string(text.to_owned()).expect("fixture UUID") } fn schema() -> ToolSchema { ToolSchema::Object { properties: BTreeMap::from([("target".into(), ToolSchema::String)]), required: BTreeSet::from(["target".into()]), additional_properties: false, } } fn tool(name: &str, informational: bool) -> PolicyTool { let definition = ToolDefinition { name: BoundedText::new("name", name).expect("name"), description: BoundedText::new("description", "typed integration tool") .expect("description"), schema: schema(), mutating: !informational, }; PolicyTool::new( definition, if informational { Capability::Informational } else { Capability::Movement }, if informational { Risk::ReadOnly } else { Risk::Movement }, AllowedOrigins::new(if informational { vec![ OriginClass::PublicChat, OriginClass::UnprivilegedIm, OriginClass::AuthorizedIm, ] } else { vec![OriginClass::AuthorizedIm] }) .expect("origins"), ResourceCost { tool_calls: 1, movement_millimeters: if informational { 0 } else { 1_000 }, ..ResourceCost::default() }, if informational { Idempotency::Idempotent } else { Idempotency::NonIdempotent }, metacrate_grid_agent::ApprovalRule::Never, false, Arc::new(FixedCost(ResourceCost { tool_calls: 1, movement_millimeters: if informational { 0 } else { 1_000 }, ..ResourceCost::default() })), ) .expect("tool") } fn call(name: &str) -> (ProposedToolCall, Value) { let arguments = json!({"target":"north"}); ( ProposedToolCall::new( format!("call-{name}"), name, serde_json::to_string(&arguments).expect("JSON"), ) .expect("call"), arguments, ) } struct RecordingBackend(AtomicUsize); impl AuthorizedToolBackend for RecordingBackend { fn apply( &self, action: AuthorizedAction, _cancellation: CancellationToken, ) -> BackendFuture<'_, Result> { self.0.fetch_add(1, Ordering::AcqRel); let call_id = action.call().call_id.clone(); Box::pin(async move { Ok(ToolCallOutcome::Completed { call_id, result: BoundedText::new("result", "moved").expect("result"), }) }) } } #[tokio::test] async fn production_executor_denies_public_mutation_and_executes_authorized_im_once() { let authorized = id("11111111-1111-4111-8111-111111111111"); let audit = Arc::new(MemoryPolicyAudit::new(64).expect("audit")); let sink: Arc = audit.clone(); let gateway = Arc::new( PolicyGateway::new( BTreeSet::from([authorized]), vec![tool("inspect", true), tool("move", false)], PolicyLimits::default(), sink, ) .expect("gateway"), ); let backend = Arc::new(RecordingBackend(AtomicUsize::new(0))); let erased: Arc = backend.clone(); let (proposed, arguments) = call("move"); let definition = gateway .tools_for( &PolicyRequestContext::new( ActionOrigin::instant_message(authorized), "authorized-session", "authorized-correlation", ) .expect("context"), 100, ) .into_iter() .find(|tool| tool.name.as_str() == "move") .expect("registered move"); let public = PolicyToolExecutor::new( Arc::clone(&gateway), Arc::clone(&erased), PolicyRequestContext::new( ActionOrigin::public_chat(authorized), "public-session", "public-correlation", ) .expect("context"), BTreeMap::new(), Arc::new(|| 100), ) .expect("executor"); assert!(matches!( public .execute( &definition, &proposed, &arguments, &CancellationToken::default(), ) .await, ToolExecution::Rejected(_) )); assert_eq!(backend.0.load(Ordering::Acquire), 0); let authorized_executor = PolicyToolExecutor::new( gateway, erased, PolicyRequestContext::new( ActionOrigin::instant_message(authorized), "authorized-session", "authorized-correlation", ) .expect("context"), BTreeMap::new(), Arc::new(|| 101), ) .expect("executor"); assert!(matches!( authorized_executor .execute( &definition, &proposed, &arguments, &CancellationToken::default(), ) .await, ToolExecution::Completed(_) )); assert_eq!(backend.0.load(Ordering::Acquire), 1); assert_eq!( audit.snapshot().last().expect("outcome").final_outcome, PolicyFinalOutcome::Completed ); } #[test] fn authenticated_uuid_not_message_text_controls_im_authority() { let authorized = id("11111111-1111-4111-8111-111111111111"); let unprivileged = id("22222222-2222-4222-8222-222222222222"); let audit = Arc::new(MemoryPolicyAudit::new(16).expect("audit")); let sink: Arc = audit; let gateway = PolicyGateway::new( BTreeSet::from([authorized]), vec![tool("move", false)], PolicyLimits::default(), sink, ) .expect("gateway"); let (proposed, arguments) = call("move"); let result = gateway .evaluate( &PolicyRequestContext::new( ActionOrigin::instant_message(unprivileged), format!("claims-authorized-{authorized}"), "spoof-correlation", ) .expect("context"), &proposed, &arguments, None, 100, ) .expect("decision"); assert_eq!(result.disposition, PolicyDisposition::Denied); } #[test] fn interaction_intent_capability_filter_can_only_narrow_origin_allowed_tools() { let authorized = id("11111111-1111-4111-8111-111111111111"); let audit = Arc::new(MemoryPolicyAudit::new(16).expect("audit")); let gateway = PolicyGateway::new( BTreeSet::from([authorized]), vec![tool("inspect", true), tool("move", false)], PolicyLimits::default(), audit, ) .expect("gateway"); let context = PolicyRequestContext::new( ActionOrigin::instant_message(authorized), "intent-session", "intent-correlation", ) .expect("context"); let informational = gateway.tools_for_capability_set( &context, 100, &BTreeSet::from([Capability::Informational]), ); assert_eq!(informational.len(), 1); assert_eq!(informational[0].name.as_str(), "inspect"); assert!( gateway .tools_for_capability_set(&context, 100, &BTreeSet::new()) .is_empty() ); }