feat(grid-agent): enforce central action policy (#120)
Some checks failed
CI / rust-skia (Rust only) (push) Successful in 2m44s
CI / required (push) Failing after 2m42s

This commit is contained in:
2026-08-17 21:18:52 +00:00
parent a46bc42a8f
commit e3b9d575f9
13 changed files with 3288 additions and 33 deletions

View File

@@ -14,6 +14,7 @@ libremetaverse-types = { version = "0.0.1", path = "../libremetaverse-types" }
reqwest = { version = "0.13.4", default-features = false, features = ["rustls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.11"
tokio = { version = "1.53.1", features = ["macros", "rt", "sync", "time"] }
url = "2.5.8"

View File

@@ -40,4 +40,7 @@ cargo run --locked -p metacrate-grid-agent -- \
See [`../../docs/grid-agent-architecture.md`](../../docs/grid-agent-architecture.md)
for queue/task ownership, shutdown, and trust boundaries. See
[`../../docs/grid-agent-llm.md`](../../docs/grid-agent-llm.md) for the LLM wire
compatibility envelope, retry rules, and tool-loop safety contract.
compatibility envelope, retry rules, and tool-loop safety contract. The central
origin matrix, approval binding, budgets, prompt-data boundary, and opaque
backend authorization are specified in
[`../../docs/grid-agent-policy.md`](../../docs/grid-agent-policy.md).

View File

@@ -1,6 +1,7 @@
//! Narrow injected boundaries between orchestration and grid/world I/O.
use crate::types::{GridEvent, GridEventKind, PolicyDecision, ProposedToolCall, ToolCallOutcome};
use crate::policy::AuthorizedAction;
use crate::types::{GridEvent, GridEventKind, ToolCallOutcome};
use libremetaverse_types::compat::CancellationToken;
use std::error::Error;
use std::fmt;
@@ -49,18 +50,21 @@ pub trait GridBackend: Send + Sync + 'static {
) -> BackendFuture<'_, Result<(), BackendError>>;
}
/// The sole world-mutation boundary. Later tool implementations cannot bypass
/// the policy decision passed to this trait, and fake/live implementations use
/// the same call shape.
pub trait WorldMutator: Send + Sync + 'static {
/// Backend for an already authorized tool action. `AuthorizedAction` has no
/// public constructor and binds the exact name, arguments, principal, budget,
/// and one policy authorization.
pub trait AuthorizedToolBackend: Send + Sync + 'static {
fn apply(
&self,
call: ProposedToolCall,
decision: PolicyDecision,
action: AuthorizedAction,
cancellation: CancellationToken,
) -> BackendFuture<'_, Result<ToolCallOutcome, BackendError>>;
}
/// Marker for world-mutating backends. Production mutations use the same
/// opaque authorization boundary as every other tool backend.
pub trait WorldMutator: AuthorizedToolBackend {}
/// Inert deterministic backend used by the foundational offline service.
///
/// It performs no login or network operation and is available without the

View File

@@ -7,13 +7,20 @@
pub mod backend;
pub mod config;
pub mod llm;
pub mod policy;
pub mod service;
pub mod tool_loop;
pub mod types;
#[cfg(test)]
mod policy_tests;
#[cfg(feature = "live-grid")]
pub use backend::LibremetaverseClientOwner;
pub use backend::{BackendError, BackendFuture, GridBackend, OfflineGridBackend, WorldMutator};
pub use backend::{
AuthorizedToolBackend, BackendError, BackendFuture, GridBackend, OfflineGridBackend,
WorldMutator,
};
pub use config::{
AgentConfig, BehaviorSettings, ConfigError, ConfigLoader, EndpointUrl, Environment,
GridConnection, Limits, LlmConnection, MapEnvironment, OperatingMode, SecretString,
@@ -23,6 +30,14 @@ pub use llm::{
Completion, CompletionMessage, ContentPart, ImageDetail, LlmClient, LlmError,
LlmTransportLimits, ToolDefinition, ToolSchema, Usage,
};
pub use policy::{
ActionOrigin, AllowedOrigins, ApprovalId, ApprovalRule, AuthenticatedPrincipal,
AuthorizedAction, BudgetLimits, Capability, FixedCost, Idempotency, MemoryPolicyAudit,
OriginClass, PolicyAuditError, PolicyAuditRecord, PolicyAuditSink, PolicyDisposition,
PolicyError, PolicyEvaluation, PolicyFinalOutcome, PolicyGateway, PolicyLimits,
PolicyReasonCode, PolicyRequestContext, PolicySnapshot, PolicyTool, PolicyToolExecutor,
ResourceCost, ResourceEstimator, Risk, SchedulerGrantId, UntrustedData, UntrustedSource,
};
pub use service::{AgentService, ServiceError, ServiceHandle, ServiceState};
pub use tool_loop::{
HistorySummarizer, SessionGeneration, ToolExecution, ToolExecutor, ToolFuture, ToolLoop,
@@ -30,6 +45,6 @@ pub use tool_loop::{
};
pub use types::{
BoundaryError, BoundedText, BoundedVec, ControlCommand, Conversation, ConversationMessage,
GridEvent, GridEventKind, LlmRequest, LlmResult, MessageRole, ObservableEvent, PolicyDecision,
GridEvent, GridEventKind, LlmRequest, LlmResult, MessageRole, ObservableEvent,
ProposedToolCall, ToolCallOutcome,
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -21,8 +21,9 @@ const MAX_SESSION_TOOL_CALLS: usize = 256;
pub type ToolFuture<'a> = Pin<Box<dyn Future<Output = ToolExecution> + Send + 'a>>;
/// Downstream execution boundary. Issue #120 can implement policy evaluation
/// here; this loop guarantees its input already passed name/schema checks.
/// Downstream execution boundary. Production wiring uses
/// [`crate::policy::PolicyToolExecutor`]; this loop guarantees its input already
/// passed name/schema checks, and the policy gateway validates it again.
pub trait ToolExecutor: Send + Sync {
fn execute<'a>(
&'a self,

View File

@@ -347,19 +347,6 @@ impl ProposedToolCall {
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PolicyDecision {
Approved {
authorization_id: u64,
},
Denied {
reason: BoundedText<MAX_OBSERVABLE_DETAIL_BYTES>,
},
NeedsOperatorApproval {
prompt: BoundedText<MAX_OBSERVABLE_DETAIL_BYTES>,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LlmResult {
pub request_id: u64,
@@ -393,10 +380,7 @@ pub enum ObservableEvent {
state: &'static str,
},
Grid(GridEvent),
Policy {
call_id: BoundedText<MAX_IDENTIFIER_BYTES>,
decision: PolicyDecision,
},
Policy(crate::policy::PolicyAuditRecord),
Tool(ToolCallOutcome),
Diagnostic {
detail: BoundedText<MAX_OBSERVABLE_DETAIL_BYTES>,

View File

@@ -2,12 +2,13 @@ use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
const ALLOWED_DEPENDENCIES: [&str; 7] = [
const ALLOWED_DEPENDENCIES: [&str; 8] = [
"libremetaverse",
"libremetaverse-types",
"reqwest",
"serde",
"serde_json",
"sha2",
"tokio",
"url",
];
@@ -45,7 +46,7 @@ fn runtime_source_has_no_subprocess_or_native_abi_escape_hatch() {
let mut files = Vec::with_capacity(8);
collect_rust_files(&source, &mut files);
assert!(
files.len() <= 8,
files.len() <= 10,
"source-file count needs a reviewed bound update"
);
for path in files {
@@ -76,6 +77,15 @@ fn runtime_source_has_no_subprocess_or_native_abi_escape_hatch() {
}
}
#[test]
fn world_backend_requires_the_opaque_policy_authorization() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let backend = fs::read_to_string(root.join("src/backend.rs")).expect("backend source");
assert!(backend.contains("action: AuthorizedAction"));
assert!(!backend.contains("call: ProposedToolCall"));
assert!(!backend.contains("decision: PolicyDecision"));
}
fn collect_rust_files(directory: &Path, output: &mut Vec<PathBuf>) {
for entry in fs::read_dir(directory).expect("read source directory") {
let path = entry.expect("source entry").path();

View File

@@ -0,0 +1,227 @@
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);
}