feat(grid-agent): enforce central action policy (#120)
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2203,6 +2203,7 @@ dependencies = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2 0.11.0",
|
||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ libremetaverse-types = { version = "0.0.1", path = "../libremetaverse-types" }
|
|||||||
reqwest = { version = "0.13.4", default-features = false, features = ["rustls"] }
|
reqwest = { version = "0.13.4", default-features = false, features = ["rustls"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
sha2 = "0.11"
|
||||||
tokio = { version = "1.53.1", features = ["macros", "rt", "sync", "time"] }
|
tokio = { version = "1.53.1", features = ["macros", "rt", "sync", "time"] }
|
||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
|
|
||||||
|
|||||||
@@ -40,4 +40,7 @@ cargo run --locked -p metacrate-grid-agent -- \
|
|||||||
See [`../../docs/grid-agent-architecture.md`](../../docs/grid-agent-architecture.md)
|
See [`../../docs/grid-agent-architecture.md`](../../docs/grid-agent-architecture.md)
|
||||||
for queue/task ownership, shutdown, and trust boundaries. See
|
for queue/task ownership, shutdown, and trust boundaries. See
|
||||||
[`../../docs/grid-agent-llm.md`](../../docs/grid-agent-llm.md) for the LLM wire
|
[`../../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).
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Narrow injected boundaries between orchestration and grid/world I/O.
|
//! 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 libremetaverse_types::compat::CancellationToken;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
@@ -49,18 +50,21 @@ pub trait GridBackend: Send + Sync + 'static {
|
|||||||
) -> BackendFuture<'_, Result<(), BackendError>>;
|
) -> BackendFuture<'_, Result<(), BackendError>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The sole world-mutation boundary. Later tool implementations cannot bypass
|
/// Backend for an already authorized tool action. `AuthorizedAction` has no
|
||||||
/// the policy decision passed to this trait, and fake/live implementations use
|
/// public constructor and binds the exact name, arguments, principal, budget,
|
||||||
/// the same call shape.
|
/// and one policy authorization.
|
||||||
pub trait WorldMutator: Send + Sync + 'static {
|
pub trait AuthorizedToolBackend: Send + Sync + 'static {
|
||||||
fn apply(
|
fn apply(
|
||||||
&self,
|
&self,
|
||||||
call: ProposedToolCall,
|
action: AuthorizedAction,
|
||||||
decision: PolicyDecision,
|
|
||||||
cancellation: CancellationToken,
|
cancellation: CancellationToken,
|
||||||
) -> BackendFuture<'_, Result<ToolCallOutcome, BackendError>>;
|
) -> 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.
|
/// Inert deterministic backend used by the foundational offline service.
|
||||||
///
|
///
|
||||||
/// It performs no login or network operation and is available without the
|
/// It performs no login or network operation and is available without the
|
||||||
|
|||||||
@@ -7,13 +7,20 @@
|
|||||||
pub mod backend;
|
pub mod backend;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod llm;
|
pub mod llm;
|
||||||
|
pub mod policy;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod tool_loop;
|
pub mod tool_loop;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod policy_tests;
|
||||||
|
|
||||||
#[cfg(feature = "live-grid")]
|
#[cfg(feature = "live-grid")]
|
||||||
pub use backend::LibremetaverseClientOwner;
|
pub use backend::LibremetaverseClientOwner;
|
||||||
pub use backend::{BackendError, BackendFuture, GridBackend, OfflineGridBackend, WorldMutator};
|
pub use backend::{
|
||||||
|
AuthorizedToolBackend, BackendError, BackendFuture, GridBackend, OfflineGridBackend,
|
||||||
|
WorldMutator,
|
||||||
|
};
|
||||||
pub use config::{
|
pub use config::{
|
||||||
AgentConfig, BehaviorSettings, ConfigError, ConfigLoader, EndpointUrl, Environment,
|
AgentConfig, BehaviorSettings, ConfigError, ConfigLoader, EndpointUrl, Environment,
|
||||||
GridConnection, Limits, LlmConnection, MapEnvironment, OperatingMode, SecretString,
|
GridConnection, Limits, LlmConnection, MapEnvironment, OperatingMode, SecretString,
|
||||||
@@ -23,6 +30,14 @@ pub use llm::{
|
|||||||
Completion, CompletionMessage, ContentPart, ImageDetail, LlmClient, LlmError,
|
Completion, CompletionMessage, ContentPart, ImageDetail, LlmClient, LlmError,
|
||||||
LlmTransportLimits, ToolDefinition, ToolSchema, Usage,
|
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 service::{AgentService, ServiceError, ServiceHandle, ServiceState};
|
||||||
pub use tool_loop::{
|
pub use tool_loop::{
|
||||||
HistorySummarizer, SessionGeneration, ToolExecution, ToolExecutor, ToolFuture, ToolLoop,
|
HistorySummarizer, SessionGeneration, ToolExecution, ToolExecutor, ToolFuture, ToolLoop,
|
||||||
@@ -30,6 +45,6 @@ pub use tool_loop::{
|
|||||||
};
|
};
|
||||||
pub use types::{
|
pub use types::{
|
||||||
BoundaryError, BoundedText, BoundedVec, ControlCommand, Conversation, ConversationMessage,
|
BoundaryError, BoundedText, BoundedVec, ControlCommand, Conversation, ConversationMessage,
|
||||||
GridEvent, GridEventKind, LlmRequest, LlmResult, MessageRole, ObservableEvent, PolicyDecision,
|
GridEvent, GridEventKind, LlmRequest, LlmResult, MessageRole, ObservableEvent,
|
||||||
ProposedToolCall, ToolCallOutcome,
|
ProposedToolCall, ToolCallOutcome,
|
||||||
};
|
};
|
||||||
|
|||||||
1859
crates/metacrate-grid-agent/src/policy.rs
Normal file
1859
crates/metacrate-grid-agent/src/policy.rs
Normal file
File diff suppressed because it is too large
Load Diff
1063
crates/metacrate-grid-agent/src/policy_tests.rs
Normal file
1063
crates/metacrate-grid-agent/src/policy_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -21,8 +21,9 @@ const MAX_SESSION_TOOL_CALLS: usize = 256;
|
|||||||
|
|
||||||
pub type ToolFuture<'a> = Pin<Box<dyn Future<Output = ToolExecution> + Send + 'a>>;
|
pub type ToolFuture<'a> = Pin<Box<dyn Future<Output = ToolExecution> + Send + 'a>>;
|
||||||
|
|
||||||
/// Downstream execution boundary. Issue #120 can implement policy evaluation
|
/// Downstream execution boundary. Production wiring uses
|
||||||
/// here; this loop guarantees its input already passed name/schema checks.
|
/// [`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 {
|
pub trait ToolExecutor: Send + Sync {
|
||||||
fn execute<'a>(
|
fn execute<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
|
|||||||
@@ -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)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct LlmResult {
|
pub struct LlmResult {
|
||||||
pub request_id: u64,
|
pub request_id: u64,
|
||||||
@@ -393,10 +380,7 @@ pub enum ObservableEvent {
|
|||||||
state: &'static str,
|
state: &'static str,
|
||||||
},
|
},
|
||||||
Grid(GridEvent),
|
Grid(GridEvent),
|
||||||
Policy {
|
Policy(crate::policy::PolicyAuditRecord),
|
||||||
call_id: BoundedText<MAX_IDENTIFIER_BYTES>,
|
|
||||||
decision: PolicyDecision,
|
|
||||||
},
|
|
||||||
Tool(ToolCallOutcome),
|
Tool(ToolCallOutcome),
|
||||||
Diagnostic {
|
Diagnostic {
|
||||||
detail: BoundedText<MAX_OBSERVABLE_DETAIL_BYTES>,
|
detail: BoundedText<MAX_OBSERVABLE_DETAIL_BYTES>,
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ use std::collections::BTreeSet;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
const ALLOWED_DEPENDENCIES: [&str; 7] = [
|
const ALLOWED_DEPENDENCIES: [&str; 8] = [
|
||||||
"libremetaverse",
|
"libremetaverse",
|
||||||
"libremetaverse-types",
|
"libremetaverse-types",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
];
|
];
|
||||||
@@ -45,7 +46,7 @@ fn runtime_source_has_no_subprocess_or_native_abi_escape_hatch() {
|
|||||||
let mut files = Vec::with_capacity(8);
|
let mut files = Vec::with_capacity(8);
|
||||||
collect_rust_files(&source, &mut files);
|
collect_rust_files(&source, &mut files);
|
||||||
assert!(
|
assert!(
|
||||||
files.len() <= 8,
|
files.len() <= 10,
|
||||||
"source-file count needs a reviewed bound update"
|
"source-file count needs a reviewed bound update"
|
||||||
);
|
);
|
||||||
for path in files {
|
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>) {
|
fn collect_rust_files(directory: &Path, output: &mut Vec<PathBuf>) {
|
||||||
for entry in fs::read_dir(directory).expect("read source directory") {
|
for entry in fs::read_dir(directory).expect("read source directory") {
|
||||||
let path = entry.expect("source entry").path();
|
let path = entry.expect("source entry").path();
|
||||||
|
|||||||
227
crates/metacrate-grid-agent/tests/policy_gateway.rs
Normal file
227
crates/metacrate-grid-agent/tests/policy_gateway.rs
Normal 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);
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ handles, the control sender, and observable receiver.
|
|||||||
| Conversation / tool calls | request owner | 256 messages / 64 calls, with lower configured limits | rejected before request |
|
| Conversation / tool calls | request owner | 256 messages / 64 calls, with lower configured limits | rejected before request |
|
||||||
| LLM request slots | shared `LlmClient` semaphore | 256 hard / configured concurrent requests | async acquire or cancellation |
|
| LLM request slots | shared `LlmClient` semaphore | 256 hard / configured concurrent requests | async acquire or cancellation |
|
||||||
| Reasoning/tool session | `ToolLoop` caller | 32 turns / 256 calls hard, with lower configured limits | total timeout, cancellation, or supersession |
|
| Reasoning/tool session | `ToolLoop` caller | 32 turns / 256 calls hard, with lower configured limits | total timeout, cancellation, or supersession |
|
||||||
|
| Policy tools / approvals / schedules | `PolicyGateway` mutex | 64 tools / 4,096 approval records / 1,024 scheduler grants hard | deny before opaque authorization |
|
||||||
|
| Principal/global resource budget | `PolicyGateway` time window | validated calls, zero L$, upload, inventory, movement, and build ceilings | atomic charge or stable denial |
|
||||||
| Authorized avatars | immutable `AgentConfig` set | 1,024 hard ceiling, lower configured limit | malformed, nil, duplicate, and wildcard input rejected |
|
| Authorized avatars | immutable `AgentConfig` set | 1,024 hard ceiling, lower configured limit | malformed, nil, duplicate, and wildcard input rejected |
|
||||||
| Configuration / secret file | loader | 64 KiB / 16 KiB | regular non-symlink file only |
|
| Configuration / secret file | loader | 64 KiB / 16 KiB | regular non-symlink file only |
|
||||||
|
|
||||||
@@ -65,7 +67,8 @@ shutdown.
|
|||||||
The `live-grid` feature supplies `LibremetaverseClientOwner`; live
|
The `live-grid` feature supplies `LibremetaverseClientOwner`; live
|
||||||
implementations must own it and reuse its client and managers.
|
implementations must own it and reuse its client and managers.
|
||||||
- World changes cross only `WorldMutator::apply`, which always receives the
|
- World changes cross only `WorldMutator::apply`, which always receives the
|
||||||
proposed call and an explicit `PolicyDecision`. This issue supplies no live
|
non-forgeable `AuthorizedAction` produced by `PolicyGateway`. Raw calls and
|
||||||
|
caller-created decisions are not accepted. This issue supplies no live
|
||||||
mutation implementation.
|
mutation implementation.
|
||||||
- LLM traffic crosses one exact configured URL through `LlmClient`. Redirects
|
- LLM traffic crosses one exact configured URL through `LlmClient`. Redirects
|
||||||
are refused, response bodies are bounded while streaming, bearer secrets are
|
are refused, response bodies are bounded while streaming, bearer secrets are
|
||||||
@@ -89,7 +92,9 @@ typed config/events/policy boundaries live-grid feature boundary
|
|||||||
|
|
||||||
avatar session -> bounded ToolLoop -> exact-endpoint LlmClient
|
avatar session -> bounded ToolLoop -> exact-endpoint LlmClient
|
||||||
|
|
|
|
||||||
+-> validated ToolExecutor boundary
|
+-> PolicyToolExecutor -> PolicyGateway
|
||||||
|
|
|
||||||
|
+-> AuthorizedToolBackend
|
||||||
```
|
```
|
||||||
|
|
||||||
The package has no build script or direct native dependency. The focused
|
The package has no build script or direct native dependency. The focused
|
||||||
@@ -97,3 +102,5 @@ The package has no build script or direct native dependency. The focused
|
|||||||
source, build scripts, and unreviewed direct dependency names in this package.
|
source, build scripts, and unreviewed direct dependency names in this package.
|
||||||
The precise LLM compatibility and cancellation contract is documented in
|
The precise LLM compatibility and cancellation contract is documented in
|
||||||
[`grid-agent-llm.md`](grid-agent-llm.md).
|
[`grid-agent-llm.md`](grid-agent-llm.md).
|
||||||
|
The origin/capability matrix and opaque mutation boundary are documented in
|
||||||
|
[`grid-agent-policy.md`](grid-agent-policy.md).
|
||||||
|
|||||||
80
docs/grid-agent-policy.md
Normal file
80
docs/grid-agent-policy.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Grid-agent authorization and safety policy
|
||||||
|
|
||||||
|
Every production tool action crosses `PolicyGateway` and then
|
||||||
|
`PolicyToolExecutor`. The gateway is deny-by-default: a tool must be registered
|
||||||
|
with its typed argument schema, capability, read/write risk, allowed origins,
|
||||||
|
maximum resource cost, deterministic cost estimator, idempotency,
|
||||||
|
approval rule, and scheduler eligibility. A tool description is model context,
|
||||||
|
not authority, and is never consulted by policy.
|
||||||
|
|
||||||
|
## Identity and origin matrix
|
||||||
|
|
||||||
|
Grid authority comes only from the sender UUID carried by the grid event.
|
||||||
|
Names, message bodies, UUID text embedded in messages, and tool arguments cannot
|
||||||
|
select an origin. Local-operator principals can only be constructed by the
|
||||||
|
crate's authenticated control-plane boundary. Scheduler contexts require an
|
||||||
|
opaque grant previously issued from an authorized IM or operator action.
|
||||||
|
|
||||||
|
| Origin | Informational read | Public LSL delivery capability | Allow-listed mutation | Scheduled action |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Public chat, including an authorized avatar | yes | yes | no | no |
|
||||||
|
| Unprivileged IM | yes | no | no | no |
|
||||||
|
| Authorized IM | when registered | when registered | when registered | may create an exact grant |
|
||||||
|
| Authenticated local operator | when registered | when registered | when registered | may create an exact grant |
|
||||||
|
| Internal scheduler | exact grant only | no | exact grant only | bounded runs and expiry |
|
||||||
|
|
||||||
|
The public LSL capability is a narrow inventory-mutation marker for the later
|
||||||
|
script-delivery workflow; it does not permit executing generated code or any
|
||||||
|
other public command. Tool names are exact ASCII identifiers, so case changes,
|
||||||
|
newlines, smuggled names, and Unicode confusables do not resolve to registered
|
||||||
|
tools.
|
||||||
|
|
||||||
|
## Approvals and budgets
|
||||||
|
|
||||||
|
Arguments are parsed again at the gateway and must equal the arguments bound to
|
||||||
|
the proposed call. Canonical JSON is SHA-256 hashed. An approval binds that
|
||||||
|
hash, exact tool, requesting principal, expiry, and one execution. Changed
|
||||||
|
arguments, another principal, an ungranted/expired approval, or a replay is a
|
||||||
|
stable denial. Authorization is represented by non-cloneable
|
||||||
|
`AuthorizedAction`, whose fields have no public constructor; action backends
|
||||||
|
cannot accept a raw call plus a caller-created decision.
|
||||||
|
|
||||||
|
Each authorized attempt atomically charges a configured time-window budget for
|
||||||
|
both the originating principal and the whole agent. The resource vector covers
|
||||||
|
tool-call rate, L$, upload bytes, inventory operations, movement millimetres,
|
||||||
|
and build prims. Hard ceilings validate configured budgets and per-tool maximum
|
||||||
|
costs. This milestone fixes every L$ budget at zero and refuses registration or
|
||||||
|
execution for currency spend, estate/parcel changes, permanent deletion,
|
||||||
|
arbitrary inventory acceptance, and generated-code execution.
|
||||||
|
|
||||||
|
Scheduler grants preserve the originating principal and bind one tool and
|
||||||
|
argument hash. Run count and lifetime are bounded; every run is charged again.
|
||||||
|
The opaque `PolicySnapshot` preserves budgets, approvals (including consumed
|
||||||
|
replay state), and scheduler grants when a trusted persistence integration
|
||||||
|
reconstructs the gateway. No durable policy store is enabled by the current
|
||||||
|
offline service; a future store must protect snapshot integrity rather than
|
||||||
|
accept caller-authored approval data.
|
||||||
|
|
||||||
|
## Prompt and audit boundaries
|
||||||
|
|
||||||
|
Chat, IM, inventory metadata, object text, parcel data, web/LLM output, and
|
||||||
|
generated scripts use `UntrustedData`. It emits a bounded labelled JSON data
|
||||||
|
record and never contributes system instructions or tool availability. This is
|
||||||
|
defence in depth: authorization is still enforced after inference at the exact
|
||||||
|
gateway.
|
||||||
|
|
||||||
|
Every decision emits a bounded structured `PolicyAuditRecord` before an action
|
||||||
|
is authorized. It contains origin class and UUID where applicable, principal,
|
||||||
|
session/correlation IDs, exact tool, disposition, stable reason code, applied
|
||||||
|
budget, an argument hash, and outcome. The executor emits the completed,
|
||||||
|
rejected, failed, or ambiguous final outcome. Raw/secret arguments and hidden
|
||||||
|
reasoning are never stored. Audit backpressure fails closed before issuing a
|
||||||
|
new authorization.
|
||||||
|
|
||||||
|
Focused verification:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test --locked -p metacrate-grid-agent --lib policy_tests
|
||||||
|
cargo test --locked -p metacrate-grid-agent --test policy_gateway
|
||||||
|
cargo clippy --locked -p metacrate-grid-agent --all-targets -- -D warnings
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user