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",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
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>>;
|
||||
|
||||
/// 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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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();
|
||||
|
||||
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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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
|
||||
implementations must own it and reuse its client and managers.
|
||||
- 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.
|
||||
- LLM traffic crosses one exact configured URL through `LlmClient`. Redirects
|
||||
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
|
||||
|
|
||||
+-> validated ToolExecutor boundary
|
||||
+-> PolicyToolExecutor -> PolicyGateway
|
||||
|
|
||||
+-> AuthorizedToolBackend
|
||||
```
|
||||
|
||||
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.
|
||||
The precise LLM compatibility and cancellation contract is documented in
|
||||
[`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