Files
MetaCrate/crates/metacrate-grid-agent/tests/policy_gateway.rs
Chili Palmer e3b9d575f9
Some checks failed
CI / rust-skia (Rust only) (push) Successful in 2m44s
CI / required (push) Failing after 2m42s
feat(grid-agent): enforce central action policy (#120)
2026-08-17 21:18:52 +00:00

228 lines
7.0 KiB
Rust

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<ToolCallOutcome, BackendError>> {
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<dyn PolicyAuditSink> = 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<dyn AuthorizedToolBackend> = 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<dyn PolicyAuditSink> = 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);
}