1982 lines
64 KiB
Rust
1982 lines
64 KiB
Rust
//! Central deny-by-default authorization, approval, budget, and audit gateway.
|
|
|
|
#![allow(clippy::missing_errors_doc)] // Public fallible APIs share PolicyError/PolicyReasonCode.
|
|
|
|
use crate::backend::AuthorizedToolBackend;
|
|
use crate::llm::ToolDefinition;
|
|
use crate::tool_loop::{ToolExecution, ToolExecutor, ToolFuture};
|
|
use crate::types::{
|
|
BoundedText, MAX_IDENTIFIER_BYTES, MAX_MESSAGE_BYTES, ProposedToolCall, ToolCallOutcome,
|
|
};
|
|
use libremetaverse_types::UUID;
|
|
use libremetaverse_types::compat::CancellationToken;
|
|
use serde_json::{Map, Value};
|
|
use sha2::{Digest as _, Sha256};
|
|
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
|
use std::error::Error;
|
|
use std::fmt;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
const MAX_POLICY_TOOLS: usize = 64;
|
|
const MAX_AUTHORIZED_AVATARS: usize = 1_024;
|
|
const MAX_AUDIT_RECORDS: usize = 4_096;
|
|
const MAX_APPROVALS: usize = 4_096;
|
|
const MAX_SCHEDULER_GRANTS: usize = 1_024;
|
|
const MAX_BUDGET_TOOL_CALLS: u64 = 1_000_000;
|
|
const MAX_BUDGET_UPLOAD_BYTES: u64 = 1024 * 1024 * 1024;
|
|
const MAX_BUDGET_INVENTORY_OPERATIONS: u64 = 1_000_000;
|
|
const MAX_BUDGET_MOVEMENT_MILLIMETERS: u64 = 1_000_000_000_000;
|
|
const MAX_BUDGET_BUILD_PRIMS: u64 = 1_000_000;
|
|
const MAX_OPERATOR_PRINCIPAL_BYTES: usize = 96;
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
|
pub enum OriginClass {
|
|
PublicChat,
|
|
UnprivilegedIm,
|
|
AuthorizedIm,
|
|
LocalOperator,
|
|
InternalScheduler,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
enum OriginKind {
|
|
PublicChat(UUID),
|
|
InstantMessage(UUID),
|
|
LocalOperator(AuthenticatedPrincipal),
|
|
InternalScheduler(SchedulerGrantId),
|
|
}
|
|
|
|
/// Request origin whose privileged variants have no public constructor.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct ActionOrigin(OriginKind);
|
|
|
|
impl ActionOrigin {
|
|
#[must_use]
|
|
pub const fn public_chat(avatar_id: UUID) -> Self {
|
|
Self(OriginKind::PublicChat(avatar_id))
|
|
}
|
|
|
|
#[must_use]
|
|
pub const fn instant_message(avatar_id: UUID) -> Self {
|
|
Self(OriginKind::InstantMessage(avatar_id))
|
|
}
|
|
}
|
|
|
|
/// Principal produced only by the authenticated control-plane implementation.
|
|
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
|
pub struct AuthenticatedPrincipal(BoundedText<MAX_OPERATOR_PRINCIPAL_BYTES>);
|
|
|
|
impl AuthenticatedPrincipal {
|
|
pub(crate) fn from_authenticated_control(
|
|
principal: impl Into<String>,
|
|
) -> Result<Self, PolicyError> {
|
|
let principal = BoundedText::new("policy.operator_principal", principal)?;
|
|
if !principal.chars().all(|character| {
|
|
character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-' | ':')
|
|
}) {
|
|
return Err(PolicyError::InvalidPrincipal);
|
|
}
|
|
Ok(Self(principal))
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn as_str(&self) -> &str {
|
|
self.0.as_str()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct PolicyRequestContext {
|
|
origin: ActionOrigin,
|
|
session_id: BoundedText<MAX_IDENTIFIER_BYTES>,
|
|
correlation_id: BoundedText<MAX_IDENTIFIER_BYTES>,
|
|
}
|
|
|
|
impl PolicyRequestContext {
|
|
pub fn new(
|
|
origin: ActionOrigin,
|
|
session_id: impl Into<String>,
|
|
correlation_id: impl Into<String>,
|
|
) -> Result<Self, PolicyError> {
|
|
Ok(Self {
|
|
origin,
|
|
session_id: policy_identifier("policy.session_id", session_id)?,
|
|
correlation_id: policy_identifier("policy.correlation_id", correlation_id)?,
|
|
})
|
|
}
|
|
|
|
pub fn authenticated_operator(
|
|
principal: AuthenticatedPrincipal,
|
|
session_id: impl Into<String>,
|
|
correlation_id: impl Into<String>,
|
|
) -> Result<Self, PolicyError> {
|
|
Self::new(
|
|
ActionOrigin(OriginKind::LocalOperator(principal)),
|
|
session_id,
|
|
correlation_id,
|
|
)
|
|
}
|
|
|
|
pub fn scheduler(
|
|
grant: SchedulerGrantId,
|
|
session_id: impl Into<String>,
|
|
correlation_id: impl Into<String>,
|
|
) -> Result<Self, PolicyError> {
|
|
Self::new(
|
|
ActionOrigin(OriginKind::InternalScheduler(grant)),
|
|
session_id,
|
|
correlation_id,
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
enum PrincipalKey {
|
|
Avatar(UUID),
|
|
Operator(AuthenticatedPrincipal),
|
|
}
|
|
|
|
impl Ord for PrincipalKey {
|
|
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
|
self.audit_label().cmp(&other.audit_label())
|
|
}
|
|
}
|
|
|
|
impl PartialOrd for PrincipalKey {
|
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
|
Some(self.cmp(other))
|
|
}
|
|
}
|
|
|
|
impl PrincipalKey {
|
|
fn audit_label(&self) -> String {
|
|
match self {
|
|
Self::Avatar(id) => format!("avatar:{id}"),
|
|
Self::Operator(id) => format!("operator:{}", id.as_str()),
|
|
}
|
|
}
|
|
|
|
const fn avatar_id(&self) -> Option<UUID> {
|
|
match self {
|
|
Self::Avatar(id) => Some(*id),
|
|
Self::Operator(_) => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
|
pub enum Capability {
|
|
Informational,
|
|
PublicLslRequest,
|
|
InventoryMutation,
|
|
Movement,
|
|
Build,
|
|
ObjectMutation,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum Risk {
|
|
ReadOnly,
|
|
InventoryMutation,
|
|
Movement,
|
|
Build,
|
|
ObjectMutation,
|
|
CurrencySpend,
|
|
EstateOrParcelChange,
|
|
PermanentDelete,
|
|
ArbitraryInventoryAcceptance,
|
|
GeneratedCodeExecution,
|
|
}
|
|
|
|
impl Risk {
|
|
const fn forbidden_in_milestone(self) -> bool {
|
|
matches!(
|
|
self,
|
|
Self::CurrencySpend
|
|
| Self::EstateOrParcelChange
|
|
| Self::PermanentDelete
|
|
| Self::ArbitraryInventoryAcceptance
|
|
| Self::GeneratedCodeExecution
|
|
)
|
|
}
|
|
|
|
const fn is_mutating(self) -> bool {
|
|
!matches!(self, Self::ReadOnly)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum Idempotency {
|
|
Idempotent,
|
|
NonIdempotent,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct AllowedOrigins(BTreeSet<OriginClass>);
|
|
|
|
impl AllowedOrigins {
|
|
pub fn new(origins: impl IntoIterator<Item = OriginClass>) -> Result<Self, PolicyError> {
|
|
let origins = origins.into_iter().collect::<BTreeSet<_>>();
|
|
if origins.is_empty() || origins.len() > 5 {
|
|
return Err(PolicyError::InvalidRegistration);
|
|
}
|
|
Ok(Self(origins))
|
|
}
|
|
|
|
fn contains(&self, origin: OriginClass) -> bool {
|
|
self.0.contains(&origin)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
pub struct ResourceCost {
|
|
pub tool_calls: u64,
|
|
pub linden_dollars: u64,
|
|
pub upload_bytes: u64,
|
|
pub inventory_operations: u64,
|
|
pub movement_millimeters: u64,
|
|
pub build_prims: u64,
|
|
}
|
|
|
|
/// Secret-free operator projection of one pending approval.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct PendingApprovalMetadata {
|
|
pub id: ApprovalId,
|
|
pub principal: String,
|
|
pub tool: String,
|
|
pub expires_at: u64,
|
|
pub cost: ResourceCost,
|
|
}
|
|
|
|
impl ResourceCost {
|
|
#[must_use]
|
|
pub const fn one_call() -> Self {
|
|
Self {
|
|
tool_calls: 1,
|
|
linden_dollars: 0,
|
|
upload_bytes: 0,
|
|
inventory_operations: 0,
|
|
movement_millimeters: 0,
|
|
build_prims: 0,
|
|
}
|
|
}
|
|
|
|
fn normalized(mut self) -> Self {
|
|
self.tool_calls = self.tool_calls.max(1);
|
|
self
|
|
}
|
|
|
|
fn checked_add(self, other: Self) -> Option<Self> {
|
|
Some(Self {
|
|
tool_calls: self.tool_calls.checked_add(other.tool_calls)?,
|
|
linden_dollars: self.linden_dollars.checked_add(other.linden_dollars)?,
|
|
upload_bytes: self.upload_bytes.checked_add(other.upload_bytes)?,
|
|
inventory_operations: self
|
|
.inventory_operations
|
|
.checked_add(other.inventory_operations)?,
|
|
movement_millimeters: self
|
|
.movement_millimeters
|
|
.checked_add(other.movement_millimeters)?,
|
|
build_prims: self.build_prims.checked_add(other.build_prims)?,
|
|
})
|
|
}
|
|
|
|
fn within(self, maximum: Self) -> bool {
|
|
self.tool_calls <= maximum.tool_calls
|
|
&& self.linden_dollars <= maximum.linden_dollars
|
|
&& self.upload_bytes <= maximum.upload_bytes
|
|
&& self.inventory_operations <= maximum.inventory_operations
|
|
&& self.movement_millimeters <= maximum.movement_millimeters
|
|
&& self.build_prims <= maximum.build_prims
|
|
}
|
|
|
|
fn exceeds(self, threshold: Self) -> bool {
|
|
!self.within(threshold)
|
|
}
|
|
}
|
|
|
|
pub trait ResourceEstimator: Send + Sync {
|
|
fn estimate(&self, arguments: &Value) -> Result<ResourceCost, PolicyReasonCode>;
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct FixedCost(pub ResourceCost);
|
|
|
|
impl ResourceEstimator for FixedCost {
|
|
fn estimate(&self, _arguments: &Value) -> Result<ResourceCost, PolicyReasonCode> {
|
|
Ok(self.0)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum ApprovalRule {
|
|
Never,
|
|
Always,
|
|
WhenExceeds(ResourceCost),
|
|
}
|
|
|
|
pub struct PolicyTool {
|
|
pub definition: ToolDefinition,
|
|
pub capability: Capability,
|
|
pub risk: Risk,
|
|
pub allowed_origins: AllowedOrigins,
|
|
pub maximum_cost: ResourceCost,
|
|
pub idempotency: Idempotency,
|
|
pub approval: ApprovalRule,
|
|
pub scheduler_allowed: bool,
|
|
estimator: Arc<dyn ResourceEstimator>,
|
|
}
|
|
|
|
impl fmt::Debug for PolicyTool {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter
|
|
.debug_struct("PolicyTool")
|
|
.field("definition", &self.definition)
|
|
.field("capability", &self.capability)
|
|
.field("risk", &self.risk)
|
|
.field("allowed_origins", &self.allowed_origins)
|
|
.field("maximum_cost", &self.maximum_cost)
|
|
.field("idempotency", &self.idempotency)
|
|
.field("approval", &self.approval)
|
|
.field("scheduler_allowed", &self.scheduler_allowed)
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
impl PolicyTool {
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn new(
|
|
definition: ToolDefinition,
|
|
capability: Capability,
|
|
risk: Risk,
|
|
allowed_origins: AllowedOrigins,
|
|
maximum_cost: ResourceCost,
|
|
idempotency: Idempotency,
|
|
approval: ApprovalRule,
|
|
scheduler_allowed: bool,
|
|
estimator: Arc<dyn ResourceEstimator>,
|
|
) -> Result<Self, PolicyError> {
|
|
definition.validate().map_err(PolicyError::Llm)?;
|
|
let capability_matches_risk = matches!(
|
|
(capability, risk),
|
|
(Capability::Informational, Risk::ReadOnly)
|
|
| (
|
|
Capability::PublicLslRequest | Capability::InventoryMutation,
|
|
Risk::InventoryMutation
|
|
)
|
|
| (Capability::Movement, Risk::Movement)
|
|
| (Capability::Build, Risk::Build)
|
|
| (Capability::ObjectMutation, Risk::ObjectMutation)
|
|
);
|
|
let approval_threshold_valid = match approval {
|
|
ApprovalRule::WhenExceeds(threshold) => threshold.within(maximum_cost),
|
|
ApprovalRule::Never | ApprovalRule::Always => true,
|
|
};
|
|
if risk.forbidden_in_milestone()
|
|
|| definition.mutating != risk.is_mutating()
|
|
|| !capability_matches_risk
|
|
|| !approval_threshold_valid
|
|
|| maximum_cost.tool_calls == 0
|
|
|| maximum_cost.linden_dollars != 0
|
|
|| (capability == Capability::PublicLslRequest && scheduler_allowed)
|
|
{
|
|
return Err(PolicyError::InvalidRegistration);
|
|
}
|
|
Ok(Self {
|
|
definition,
|
|
capability,
|
|
risk,
|
|
allowed_origins,
|
|
maximum_cost,
|
|
idempotency,
|
|
approval,
|
|
scheduler_allowed,
|
|
estimator,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct BudgetLimits {
|
|
pub per_principal: ResourceCost,
|
|
pub global: ResourceCost,
|
|
pub window_seconds: u64,
|
|
}
|
|
|
|
impl Default for BudgetLimits {
|
|
fn default() -> Self {
|
|
Self {
|
|
per_principal: ResourceCost {
|
|
tool_calls: 32,
|
|
linden_dollars: 0,
|
|
upload_bytes: 4 * 1024 * 1024,
|
|
inventory_operations: 32,
|
|
movement_millimeters: 100_000,
|
|
build_prims: 32,
|
|
},
|
|
global: ResourceCost {
|
|
tool_calls: 256,
|
|
linden_dollars: 0,
|
|
upload_bytes: 16 * 1024 * 1024,
|
|
inventory_operations: 128,
|
|
movement_millimeters: 500_000,
|
|
build_prims: 128,
|
|
},
|
|
window_seconds: 60,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct PolicyLimits {
|
|
pub budgets: BudgetLimits,
|
|
pub approval_ttl_seconds: u64,
|
|
pub max_pending_approvals: usize,
|
|
pub max_scheduler_grants: usize,
|
|
pub max_scheduler_runs_per_grant: u64,
|
|
pub max_scheduler_ttl_seconds: u64,
|
|
}
|
|
|
|
impl Default for PolicyLimits {
|
|
fn default() -> Self {
|
|
Self {
|
|
budgets: BudgetLimits::default(),
|
|
approval_ttl_seconds: 300,
|
|
max_pending_approvals: 128,
|
|
max_scheduler_grants: 128,
|
|
max_scheduler_runs_per_grant: 1_024,
|
|
max_scheduler_ttl_seconds: 86_400,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PolicyLimits {
|
|
fn validate(self) -> Result<Self, PolicyError> {
|
|
if self.budgets.window_seconds == 0
|
|
|| self.budgets.window_seconds > 86_400
|
|
|| self.budgets.per_principal.tool_calls == 0
|
|
|| self.budgets.global.tool_calls == 0
|
|
|| self.budgets.per_principal.linden_dollars != 0
|
|
|| self.budgets.global.linden_dollars != 0
|
|
|| self.approval_ttl_seconds == 0
|
|
|| self.approval_ttl_seconds > 86_400
|
|
|| self.max_pending_approvals == 0
|
|
|| self.max_pending_approvals > MAX_APPROVALS
|
|
|| self.max_scheduler_grants == 0
|
|
|| self.max_scheduler_grants > MAX_SCHEDULER_GRANTS
|
|
|| self.max_scheduler_runs_per_grant == 0
|
|
|| self.max_scheduler_runs_per_grant > 10_000
|
|
|| self.max_scheduler_ttl_seconds == 0
|
|
|| self.max_scheduler_ttl_seconds > 7 * 86_400
|
|
|| !safe_budget(self.budgets.per_principal)
|
|
|| !safe_budget(self.budgets.global)
|
|
|| !self.budgets.per_principal.within(self.budgets.global)
|
|
{
|
|
return Err(PolicyError::UnsafeLimits);
|
|
}
|
|
Ok(self)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum PolicyDisposition {
|
|
Allowed,
|
|
Denied,
|
|
ApprovalRequired,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum PolicyReasonCode {
|
|
Allowed,
|
|
UnknownTool,
|
|
InvalidArguments,
|
|
OriginDenied,
|
|
CapabilityDenied,
|
|
ForbiddenMilestoneOperation,
|
|
ToolCostExceeded,
|
|
PrincipalBudgetExceeded,
|
|
GlobalBudgetExceeded,
|
|
ApprovalRequired,
|
|
ApprovalUnknown,
|
|
ApprovalNotGranted,
|
|
ApprovalExpired,
|
|
ApprovalReplayed,
|
|
ApprovalMismatch,
|
|
ApprovalUnexpected,
|
|
SchedulerGrantUnknown,
|
|
SchedulerGrantExpired,
|
|
SchedulerGrantExhausted,
|
|
SchedulerPrivilegeEscalation,
|
|
AuditUnavailable,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum PolicyFinalOutcome {
|
|
Denied,
|
|
ApprovalRequired,
|
|
ApprovalGranted,
|
|
Authorized,
|
|
Completed,
|
|
Rejected,
|
|
Failed,
|
|
AmbiguousMutation,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct PolicyAuditRecord {
|
|
pub recorded_unix_millis: u64,
|
|
pub authorization_id: Option<u64>,
|
|
pub approval_id: Option<ApprovalId>,
|
|
pub origin: OriginClass,
|
|
pub origin_avatar_id: Option<UUID>,
|
|
pub principal: BoundedText<MAX_IDENTIFIER_BYTES>,
|
|
pub session_id: BoundedText<MAX_IDENTIFIER_BYTES>,
|
|
pub correlation_id: BoundedText<MAX_IDENTIFIER_BYTES>,
|
|
pub tool: BoundedText<MAX_IDENTIFIER_BYTES>,
|
|
pub disposition: PolicyDisposition,
|
|
pub reason: PolicyReasonCode,
|
|
pub applied_budget: ResourceCost,
|
|
pub arguments_hash: BoundedText<64>,
|
|
pub final_outcome: PolicyFinalOutcome,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct PolicyAuditError;
|
|
|
|
impl fmt::Display for PolicyAuditError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter.write_str("policy audit sink unavailable or full")
|
|
}
|
|
}
|
|
|
|
impl Error for PolicyAuditError {}
|
|
|
|
pub trait PolicyAuditSink: Send + Sync {
|
|
fn emit(&self, record: PolicyAuditRecord) -> Result<(), PolicyAuditError>;
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct MemoryPolicyAudit {
|
|
capacity: usize,
|
|
records: Mutex<VecDeque<PolicyAuditRecord>>,
|
|
}
|
|
|
|
impl MemoryPolicyAudit {
|
|
pub fn new(capacity: usize) -> Result<Self, PolicyError> {
|
|
if capacity == 0 || capacity > MAX_AUDIT_RECORDS {
|
|
return Err(PolicyError::UnsafeLimits);
|
|
}
|
|
Ok(Self {
|
|
capacity,
|
|
records: Mutex::new(VecDeque::with_capacity(capacity)),
|
|
})
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn snapshot(&self) -> Vec<PolicyAuditRecord> {
|
|
lock(&self.records).iter().cloned().collect()
|
|
}
|
|
}
|
|
|
|
impl PolicyAuditSink for MemoryPolicyAudit {
|
|
fn emit(&self, record: PolicyAuditRecord) -> Result<(), PolicyAuditError> {
|
|
let mut records = lock(&self.records);
|
|
if records.len() == self.capacity {
|
|
return Err(PolicyAuditError);
|
|
}
|
|
records.push_back(record);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
|
pub struct ApprovalId(u64);
|
|
|
|
impl ApprovalId {
|
|
#[must_use]
|
|
pub const fn from_raw(value: u64) -> Option<Self> {
|
|
if value == 0 { None } else { Some(Self(value)) }
|
|
}
|
|
|
|
#[must_use]
|
|
pub const fn get(self) -> u64 {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
|
pub struct SchedulerGrantId(u64);
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum ApprovalStatus {
|
|
Pending,
|
|
Granted,
|
|
Denied,
|
|
Consumed,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct ApprovalRecord {
|
|
id: ApprovalId,
|
|
principal: PrincipalKey,
|
|
context: PolicyRequestContext,
|
|
origin: OriginClass,
|
|
tool: String,
|
|
arguments_hash: [u8; 32],
|
|
cost: ResourceCost,
|
|
expires_at: u64,
|
|
status: ApprovalStatus,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct SchedulerGrant {
|
|
id: SchedulerGrantId,
|
|
principal: PrincipalKey,
|
|
tool: String,
|
|
arguments_hash: [u8; 32],
|
|
expires_at: u64,
|
|
remaining_runs: u64,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct PolicyState {
|
|
next_id: u64,
|
|
window_started_at: u64,
|
|
global_used: ResourceCost,
|
|
principal_used: BTreeMap<PrincipalKey, ResourceCost>,
|
|
approvals: BTreeMap<ApprovalId, ApprovalRecord>,
|
|
scheduler_grants: BTreeMap<SchedulerGrantId, SchedulerGrant>,
|
|
}
|
|
|
|
impl Default for PolicyState {
|
|
fn default() -> Self {
|
|
Self {
|
|
next_id: 1,
|
|
window_started_at: 0,
|
|
global_used: ResourceCost::default(),
|
|
principal_used: BTreeMap::new(),
|
|
approvals: BTreeMap::new(),
|
|
scheduler_grants: BTreeMap::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Opaque persistence value. A later storage adapter can durably protect and
|
|
/// restore it without gaining a way to mint an `AuthorizedAction`.
|
|
#[derive(Clone, Debug)]
|
|
pub struct PolicySnapshot(PolicyState);
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum PolicyError {
|
|
Boundary(crate::types::BoundaryError),
|
|
Llm(crate::llm::LlmError),
|
|
UnsafeLimits,
|
|
InvalidPrincipal,
|
|
InvalidContext,
|
|
InvalidRegistration,
|
|
DuplicateTool,
|
|
TooManyTools,
|
|
AuditUnavailable,
|
|
}
|
|
|
|
impl fmt::Display for PolicyError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Boundary(error) => write!(formatter, "policy boundary rejected input: {error}"),
|
|
Self::Llm(error) => write!(formatter, "policy tool schema rejected: {error}"),
|
|
Self::UnsafeLimits => formatter.write_str("unsafe policy limits"),
|
|
Self::InvalidPrincipal => formatter.write_str("invalid authenticated principal"),
|
|
Self::InvalidContext => formatter.write_str("invalid policy request context"),
|
|
Self::InvalidRegistration => formatter.write_str("invalid policy tool registration"),
|
|
Self::DuplicateTool => formatter.write_str("duplicate policy tool registration"),
|
|
Self::TooManyTools => formatter.write_str("too many policy tool registrations"),
|
|
Self::AuditUnavailable => formatter.write_str("policy audit emission failed closed"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Error for PolicyError {}
|
|
|
|
impl From<crate::types::BoundaryError> for PolicyError {
|
|
fn from(value: crate::types::BoundaryError) -> Self {
|
|
Self::Boundary(value)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct PolicyEvaluation {
|
|
pub disposition: PolicyDisposition,
|
|
pub reason: PolicyReasonCode,
|
|
pub approval_id: Option<ApprovalId>,
|
|
authorization: Option<AuthorizedAction>,
|
|
}
|
|
|
|
impl PolicyEvaluation {
|
|
#[must_use]
|
|
pub fn into_authorization(self) -> Option<AuthorizedAction> {
|
|
self.authorization
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct ActionReceipt {
|
|
authorization_id: u64,
|
|
context: PolicyRequestContext,
|
|
origin: OriginClass,
|
|
principal: PrincipalKey,
|
|
tool: BoundedText<MAX_IDENTIFIER_BYTES>,
|
|
call_id: BoundedText<MAX_IDENTIFIER_BYTES>,
|
|
cost: ResourceCost,
|
|
arguments_hash: [u8; 32],
|
|
idempotency: Idempotency,
|
|
mutating: bool,
|
|
}
|
|
|
|
/// Single-use exact authorization. Its private fields prevent callers from
|
|
/// forging a policy decision or swapping arguments after evaluation.
|
|
///
|
|
/// ```compile_fail
|
|
/// use metacrate_grid_agent::AuthorizedAction;
|
|
/// let forged = AuthorizedAction { /* private authorization fields */ };
|
|
/// ```
|
|
#[derive(Debug)]
|
|
pub struct AuthorizedAction {
|
|
call: ProposedToolCall,
|
|
receipt: ActionReceipt,
|
|
}
|
|
|
|
impl AuthorizedAction {
|
|
#[must_use]
|
|
pub const fn call(&self) -> &ProposedToolCall {
|
|
&self.call
|
|
}
|
|
|
|
#[must_use]
|
|
pub const fn authorization_id(&self) -> u64 {
|
|
self.receipt.authorization_id
|
|
}
|
|
|
|
#[must_use]
|
|
pub const fn applied_budget(&self) -> ResourceCost {
|
|
self.receipt.cost
|
|
}
|
|
}
|
|
|
|
pub struct PolicyGateway {
|
|
authorized_avatars: BTreeSet<UUID>,
|
|
tools: BTreeMap<String, PolicyTool>,
|
|
limits: PolicyLimits,
|
|
audit: Arc<dyn PolicyAuditSink>,
|
|
state: Mutex<PolicyState>,
|
|
}
|
|
|
|
impl fmt::Debug for PolicyGateway {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter
|
|
.debug_struct("PolicyGateway")
|
|
.field("authorized_avatar_count", &self.authorized_avatars.len())
|
|
.field("tool_count", &self.tools.len())
|
|
.field("limits", &self.limits)
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
impl PolicyGateway {
|
|
pub fn new(
|
|
authorized_avatars: BTreeSet<UUID>,
|
|
tools: Vec<PolicyTool>,
|
|
limits: PolicyLimits,
|
|
audit: Arc<dyn PolicyAuditSink>,
|
|
) -> Result<Self, PolicyError> {
|
|
Self::restore(
|
|
authorized_avatars,
|
|
tools,
|
|
limits,
|
|
audit,
|
|
PolicySnapshot(PolicyState::default()),
|
|
)
|
|
}
|
|
|
|
pub fn restore(
|
|
authorized_avatars: BTreeSet<UUID>,
|
|
tools: Vec<PolicyTool>,
|
|
limits: PolicyLimits,
|
|
audit: Arc<dyn PolicyAuditSink>,
|
|
snapshot: PolicySnapshot,
|
|
) -> Result<Self, PolicyError> {
|
|
let limits = limits.validate()?;
|
|
if authorized_avatars.len() > MAX_AUTHORIZED_AVATARS
|
|
|| authorized_avatars.contains(&UUID::zero())
|
|
{
|
|
return Err(PolicyError::InvalidPrincipal);
|
|
}
|
|
if tools.len() > MAX_POLICY_TOOLS {
|
|
return Err(PolicyError::TooManyTools);
|
|
}
|
|
let mut registry = BTreeMap::new();
|
|
for tool in tools {
|
|
if !tool.maximum_cost.within(limits.budgets.per_principal)
|
|
|| !tool.maximum_cost.within(limits.budgets.global)
|
|
{
|
|
return Err(PolicyError::InvalidRegistration);
|
|
}
|
|
let name = tool.definition.name.as_str().to_owned();
|
|
if registry.insert(name, tool).is_some() {
|
|
return Err(PolicyError::DuplicateTool);
|
|
}
|
|
}
|
|
if snapshot.0.approvals.len() > MAX_APPROVALS
|
|
|| snapshot.0.scheduler_grants.len() > limits.max_scheduler_grants
|
|
{
|
|
return Err(PolicyError::UnsafeLimits);
|
|
}
|
|
Ok(Self {
|
|
authorized_avatars,
|
|
tools: registry,
|
|
limits,
|
|
audit,
|
|
state: Mutex::new(snapshot.0),
|
|
})
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn snapshot(&self) -> PolicySnapshot {
|
|
PolicySnapshot(lock(&self.state).clone())
|
|
}
|
|
|
|
/// Returns bounded, content-free approval metadata for operator UIs.
|
|
#[must_use]
|
|
pub fn pending_approvals(&self, now: u64) -> Vec<PendingApprovalMetadata> {
|
|
lock(&self.state)
|
|
.approvals
|
|
.values()
|
|
.filter(|approval| {
|
|
approval.status == ApprovalStatus::Pending && approval.expires_at > now
|
|
})
|
|
.map(|approval| PendingApprovalMetadata {
|
|
id: approval.id,
|
|
principal: approval.principal.audit_label(),
|
|
tool: approval.tool.clone(),
|
|
expires_at: approval.expires_at,
|
|
cost: approval.cost,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Current global budget use for a redacted control-plane projection.
|
|
#[must_use]
|
|
pub fn global_budget_usage(&self) -> ResourceCost {
|
|
lock(&self.state).global_used
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn pending_approval_count(&self) -> usize {
|
|
lock(&self.state)
|
|
.approvals
|
|
.values()
|
|
.filter(|approval| approval.status == ApprovalStatus::Pending)
|
|
.count()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn tools_for(&self, context: &PolicyRequestContext, now: u64) -> Vec<ToolDefinition> {
|
|
self.tools_for_capabilities(context, now, None)
|
|
}
|
|
|
|
/// Returns only origin-allowed tools whose capability is in `capabilities`.
|
|
/// An empty set exposes no tools; filtering can never broaden policy access.
|
|
#[must_use]
|
|
pub fn tools_for_capability_set(
|
|
&self,
|
|
context: &PolicyRequestContext,
|
|
now: u64,
|
|
capabilities: &BTreeSet<Capability>,
|
|
) -> Vec<ToolDefinition> {
|
|
self.tools_for_capabilities(context, now, Some(capabilities))
|
|
}
|
|
|
|
fn tools_for_capabilities(
|
|
&self,
|
|
context: &PolicyRequestContext,
|
|
now: u64,
|
|
capabilities: Option<&BTreeSet<Capability>>,
|
|
) -> Vec<ToolDefinition> {
|
|
if let OriginKind::InternalScheduler(id) = &context.origin.0 {
|
|
let state = lock(&self.state);
|
|
let Some(grant) = state.scheduler_grants.get(id) else {
|
|
return Vec::new();
|
|
};
|
|
if grant.expires_at <= now
|
|
|| grant.remaining_runs == 0
|
|
|| matches!(&grant.principal, PrincipalKey::Avatar(avatar) if !self.authorized_avatars.contains(avatar))
|
|
{
|
|
return Vec::new();
|
|
}
|
|
return self
|
|
.tools
|
|
.get(&grant.tool)
|
|
.filter(|tool| {
|
|
origin_allows(tool, OriginClass::InternalScheduler)
|
|
&& capabilities.is_none_or(|allowed| allowed.contains(&tool.capability))
|
|
})
|
|
.map(|tool| vec![tool.definition.clone()])
|
|
.unwrap_or_default();
|
|
}
|
|
let Ok(classified) = self.classify(context, now, None, None) else {
|
|
return Vec::new();
|
|
};
|
|
self.tools
|
|
.values()
|
|
.filter(|tool| {
|
|
origin_allows(tool, classified.origin)
|
|
&& capabilities.is_none_or(|allowed| allowed.contains(&tool.capability))
|
|
})
|
|
.map(|tool| tool.definition.clone())
|
|
.collect()
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)] // Ordered fail-closed checks mirror the policy audit sequence.
|
|
pub fn evaluate(
|
|
&self,
|
|
context: &PolicyRequestContext,
|
|
call: &ProposedToolCall,
|
|
arguments: &Value,
|
|
approval_id: Option<ApprovalId>,
|
|
now: u64,
|
|
) -> Result<PolicyEvaluation, PolicyError> {
|
|
let encoded_arguments = serde_json::from_str::<Value>(call.arguments_json.as_str());
|
|
let arguments_hash = match &encoded_arguments {
|
|
Ok(encoded) => hash_arguments(encoded)?,
|
|
Err(_) => Sha256::digest(call.arguments_json.as_bytes()).into(),
|
|
};
|
|
let classified = match self.classify(
|
|
context,
|
|
now,
|
|
Some(call.name.as_str()),
|
|
Some(&arguments_hash),
|
|
) {
|
|
Ok(classified) => classified,
|
|
Err(reason) => {
|
|
return self.denied(
|
|
context,
|
|
call,
|
|
fallback_principal(context),
|
|
OriginClass::InternalScheduler,
|
|
arguments_hash,
|
|
reason,
|
|
);
|
|
}
|
|
};
|
|
let Some(tool) = self.tools.get(call.name.as_str()) else {
|
|
return self.denied(
|
|
context,
|
|
call,
|
|
classified.principal,
|
|
classified.origin,
|
|
arguments_hash,
|
|
PolicyReasonCode::UnknownTool,
|
|
);
|
|
};
|
|
if encoded_arguments.map_or(true, |encoded| encoded != *arguments) {
|
|
return self.denied(
|
|
context,
|
|
call,
|
|
classified.principal,
|
|
classified.origin,
|
|
arguments_hash,
|
|
PolicyReasonCode::InvalidArguments,
|
|
);
|
|
}
|
|
if tool.definition.schema.validate_value(arguments).is_err() {
|
|
return self.denied(
|
|
context,
|
|
call,
|
|
classified.principal,
|
|
classified.origin,
|
|
arguments_hash,
|
|
PolicyReasonCode::InvalidArguments,
|
|
);
|
|
}
|
|
if !tool.allowed_origins.contains(classified.origin) {
|
|
return self.denied(
|
|
context,
|
|
call,
|
|
classified.principal,
|
|
classified.origin,
|
|
arguments_hash,
|
|
PolicyReasonCode::OriginDenied,
|
|
);
|
|
}
|
|
if !origin_allows(tool, classified.origin) {
|
|
return self.denied(
|
|
context,
|
|
call,
|
|
classified.principal,
|
|
classified.origin,
|
|
arguments_hash,
|
|
PolicyReasonCode::CapabilityDenied,
|
|
);
|
|
}
|
|
if tool.risk.forbidden_in_milestone() {
|
|
return self.denied(
|
|
context,
|
|
call,
|
|
classified.principal,
|
|
classified.origin,
|
|
arguments_hash,
|
|
PolicyReasonCode::ForbiddenMilestoneOperation,
|
|
);
|
|
}
|
|
let cost = match tool.estimator.estimate(arguments) {
|
|
Ok(cost) => cost.normalized(),
|
|
Err(reason) => {
|
|
return self.denied(
|
|
context,
|
|
call,
|
|
classified.principal,
|
|
classified.origin,
|
|
arguments_hash,
|
|
reason,
|
|
);
|
|
}
|
|
};
|
|
if cost.linden_dollars != 0 || !cost.within(tool.maximum_cost) {
|
|
return self.denied_with_cost(
|
|
context,
|
|
call,
|
|
classified.principal,
|
|
classified.origin,
|
|
arguments_hash,
|
|
cost,
|
|
if cost.linden_dollars != 0 {
|
|
PolicyReasonCode::ForbiddenMilestoneOperation
|
|
} else {
|
|
PolicyReasonCode::ToolCostExceeded
|
|
},
|
|
);
|
|
}
|
|
self.authorize(
|
|
context,
|
|
call,
|
|
classified,
|
|
arguments_hash,
|
|
cost,
|
|
tool,
|
|
approval_id,
|
|
now,
|
|
)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments, clippy::too_many_lines)] // Atomic approval/budget commit.
|
|
fn authorize(
|
|
&self,
|
|
context: &PolicyRequestContext,
|
|
call: &ProposedToolCall,
|
|
classified: ClassifiedOrigin,
|
|
arguments_hash: [u8; 32],
|
|
cost: ResourceCost,
|
|
tool: &PolicyTool,
|
|
approval_id: Option<ApprovalId>,
|
|
now: u64,
|
|
) -> Result<PolicyEvaluation, PolicyError> {
|
|
let requires_approval = match tool.approval {
|
|
ApprovalRule::Never => false,
|
|
ApprovalRule::Always => true,
|
|
ApprovalRule::WhenExceeds(threshold) => cost.exceeds(threshold),
|
|
};
|
|
let mut state = lock(&self.state);
|
|
rotate_budgets(&mut state, self.limits.budgets, now);
|
|
let mut approval_to_consume = None;
|
|
if requires_approval {
|
|
if let Some(id) = approval_id {
|
|
let Some(approval) = state.approvals.get(&id) else {
|
|
drop(state);
|
|
return self.denied_with_cost(
|
|
context,
|
|
call,
|
|
classified.principal,
|
|
classified.origin,
|
|
arguments_hash,
|
|
cost,
|
|
PolicyReasonCode::ApprovalUnknown,
|
|
);
|
|
};
|
|
let reason = if approval.expires_at <= now {
|
|
Some(PolicyReasonCode::ApprovalExpired)
|
|
} else if approval.principal != classified.principal
|
|
|| approval.tool != call.name.as_str()
|
|
|| approval.arguments_hash != arguments_hash
|
|
|| approval.cost != cost
|
|
{
|
|
Some(PolicyReasonCode::ApprovalMismatch)
|
|
} else {
|
|
match approval.status {
|
|
ApprovalStatus::Pending => Some(PolicyReasonCode::ApprovalNotGranted),
|
|
ApprovalStatus::Granted => None,
|
|
ApprovalStatus::Denied | ApprovalStatus::Consumed => {
|
|
Some(PolicyReasonCode::ApprovalReplayed)
|
|
}
|
|
}
|
|
};
|
|
if let Some(reason) = reason {
|
|
drop(state);
|
|
return self.denied_with_cost(
|
|
context,
|
|
call,
|
|
classified.principal,
|
|
classified.origin,
|
|
arguments_hash,
|
|
cost,
|
|
reason,
|
|
);
|
|
}
|
|
approval_to_consume = Some(id);
|
|
} else {
|
|
if let Some(existing) = state.approvals.values().find(|approval| {
|
|
approval.principal == classified.principal
|
|
&& approval.tool == call.name.as_str()
|
|
&& approval.arguments_hash == arguments_hash
|
|
&& approval.status == ApprovalStatus::Pending
|
|
&& approval.expires_at > now
|
|
}) {
|
|
let id = existing.id;
|
|
drop(state);
|
|
return self.approval_required(
|
|
context,
|
|
call,
|
|
classified.principal,
|
|
classified.origin,
|
|
arguments_hash,
|
|
cost,
|
|
id,
|
|
);
|
|
}
|
|
let pending = state
|
|
.approvals
|
|
.values()
|
|
.filter(|approval| {
|
|
approval.status == ApprovalStatus::Pending && approval.expires_at > now
|
|
})
|
|
.count();
|
|
if pending >= self.limits.max_pending_approvals {
|
|
drop(state);
|
|
return self.denied_with_cost(
|
|
context,
|
|
call,
|
|
classified.principal,
|
|
classified.origin,
|
|
arguments_hash,
|
|
cost,
|
|
PolicyReasonCode::PrincipalBudgetExceeded,
|
|
);
|
|
}
|
|
if state.approvals.len() == MAX_APPROVALS {
|
|
let removable = state.approvals.iter().find_map(|(id, approval)| {
|
|
(approval.expires_at <= now
|
|
|| matches!(
|
|
approval.status,
|
|
ApprovalStatus::Denied | ApprovalStatus::Consumed
|
|
))
|
|
.then_some(*id)
|
|
});
|
|
let Some(removable) = removable else {
|
|
drop(state);
|
|
return self.denied_with_cost(
|
|
context,
|
|
call,
|
|
classified.principal,
|
|
classified.origin,
|
|
arguments_hash,
|
|
cost,
|
|
PolicyReasonCode::GlobalBudgetExceeded,
|
|
);
|
|
};
|
|
state.approvals.remove(&removable);
|
|
}
|
|
let id = ApprovalId(next_id(&mut state));
|
|
let record = ApprovalRecord {
|
|
id,
|
|
principal: classified.principal.clone(),
|
|
context: context.clone(),
|
|
origin: classified.origin,
|
|
tool: call.name.as_str().to_owned(),
|
|
arguments_hash,
|
|
cost,
|
|
expires_at: now.saturating_add(self.limits.approval_ttl_seconds),
|
|
status: ApprovalStatus::Pending,
|
|
};
|
|
let audit = audit_record(
|
|
context,
|
|
classified.origin,
|
|
&classified.principal,
|
|
call.name.clone(),
|
|
None,
|
|
Some(id),
|
|
PolicyDisposition::ApprovalRequired,
|
|
PolicyReasonCode::ApprovalRequired,
|
|
cost,
|
|
arguments_hash,
|
|
PolicyFinalOutcome::ApprovalRequired,
|
|
)?;
|
|
self.emit(audit)?;
|
|
state.approvals.insert(id, record);
|
|
return Ok(PolicyEvaluation {
|
|
disposition: PolicyDisposition::ApprovalRequired,
|
|
reason: PolicyReasonCode::ApprovalRequired,
|
|
approval_id: Some(id),
|
|
authorization: None,
|
|
});
|
|
}
|
|
} else if approval_id.is_some() {
|
|
drop(state);
|
|
return self.denied_with_cost(
|
|
context,
|
|
call,
|
|
classified.principal,
|
|
classified.origin,
|
|
arguments_hash,
|
|
cost,
|
|
PolicyReasonCode::ApprovalUnexpected,
|
|
);
|
|
}
|
|
let principal_used = state
|
|
.principal_used
|
|
.get(&classified.principal)
|
|
.copied()
|
|
.unwrap_or_default();
|
|
let Some(next_principal) = principal_used.checked_add(cost) else {
|
|
drop(state);
|
|
return self.denied_with_cost(
|
|
context,
|
|
call,
|
|
classified.principal,
|
|
classified.origin,
|
|
arguments_hash,
|
|
cost,
|
|
PolicyReasonCode::PrincipalBudgetExceeded,
|
|
);
|
|
};
|
|
if !next_principal.within(self.limits.budgets.per_principal) {
|
|
drop(state);
|
|
return self.denied_with_cost(
|
|
context,
|
|
call,
|
|
classified.principal,
|
|
classified.origin,
|
|
arguments_hash,
|
|
cost,
|
|
PolicyReasonCode::PrincipalBudgetExceeded,
|
|
);
|
|
}
|
|
let Some(next_global) = state.global_used.checked_add(cost) else {
|
|
drop(state);
|
|
return self.denied_with_cost(
|
|
context,
|
|
call,
|
|
classified.principal,
|
|
classified.origin,
|
|
arguments_hash,
|
|
cost,
|
|
PolicyReasonCode::GlobalBudgetExceeded,
|
|
);
|
|
};
|
|
if !next_global.within(self.limits.budgets.global) {
|
|
drop(state);
|
|
return self.denied_with_cost(
|
|
context,
|
|
call,
|
|
classified.principal,
|
|
classified.origin,
|
|
arguments_hash,
|
|
cost,
|
|
PolicyReasonCode::GlobalBudgetExceeded,
|
|
);
|
|
}
|
|
let authorization_id = next_id(&mut state);
|
|
let record = audit_record(
|
|
context,
|
|
classified.origin,
|
|
&classified.principal,
|
|
call.name.clone(),
|
|
Some(authorization_id),
|
|
approval_to_consume,
|
|
PolicyDisposition::Allowed,
|
|
PolicyReasonCode::Allowed,
|
|
cost,
|
|
arguments_hash,
|
|
PolicyFinalOutcome::Authorized,
|
|
)?;
|
|
self.emit(record)?;
|
|
state.global_used = next_global;
|
|
state
|
|
.principal_used
|
|
.insert(classified.principal.clone(), next_principal);
|
|
if let Some(id) = approval_to_consume
|
|
&& let Some(approval) = state.approvals.get_mut(&id)
|
|
{
|
|
approval.status = ApprovalStatus::Consumed;
|
|
}
|
|
if let Some(grant_id) = classified.scheduler_grant
|
|
&& let Some(grant) = state.scheduler_grants.get_mut(&grant_id)
|
|
{
|
|
grant.remaining_runs = grant.remaining_runs.saturating_sub(1);
|
|
}
|
|
let receipt = ActionReceipt {
|
|
authorization_id,
|
|
context: context.clone(),
|
|
origin: classified.origin,
|
|
principal: classified.principal,
|
|
tool: call.name.clone(),
|
|
call_id: call.call_id.clone(),
|
|
cost,
|
|
arguments_hash,
|
|
idempotency: tool.idempotency,
|
|
mutating: tool.risk.is_mutating(),
|
|
};
|
|
Ok(PolicyEvaluation {
|
|
disposition: PolicyDisposition::Allowed,
|
|
reason: PolicyReasonCode::Allowed,
|
|
approval_id: approval_to_consume,
|
|
authorization: Some(AuthorizedAction {
|
|
call: call.clone(),
|
|
receipt,
|
|
}),
|
|
})
|
|
}
|
|
|
|
pub fn grant_approval(
|
|
&self,
|
|
id: ApprovalId,
|
|
_operator: &AuthenticatedPrincipal,
|
|
now: u64,
|
|
) -> Result<PolicyReasonCode, PolicyError> {
|
|
let mut state = lock(&self.state);
|
|
let Some(approval) = state.approvals.get_mut(&id) else {
|
|
return Ok(PolicyReasonCode::ApprovalUnknown);
|
|
};
|
|
if approval.expires_at <= now {
|
|
return Ok(PolicyReasonCode::ApprovalExpired);
|
|
}
|
|
if approval.status != ApprovalStatus::Pending {
|
|
return Ok(PolicyReasonCode::ApprovalReplayed);
|
|
}
|
|
let audit = audit_record(
|
|
&approval.context,
|
|
approval.origin,
|
|
&approval.principal,
|
|
BoundedText::new("policy.tool", approval.tool.clone())?,
|
|
None,
|
|
Some(id),
|
|
PolicyDisposition::Allowed,
|
|
PolicyReasonCode::Allowed,
|
|
approval.cost,
|
|
approval.arguments_hash,
|
|
PolicyFinalOutcome::ApprovalGranted,
|
|
)?;
|
|
self.emit(audit)?;
|
|
approval.status = ApprovalStatus::Granted;
|
|
Ok(PolicyReasonCode::Allowed)
|
|
}
|
|
|
|
pub fn deny_approval(
|
|
&self,
|
|
id: ApprovalId,
|
|
_operator: &AuthenticatedPrincipal,
|
|
now: u64,
|
|
) -> Result<PolicyReasonCode, PolicyError> {
|
|
let mut state = lock(&self.state);
|
|
let Some(approval) = state.approvals.get_mut(&id) else {
|
|
return Ok(PolicyReasonCode::ApprovalUnknown);
|
|
};
|
|
if approval.expires_at <= now {
|
|
return Ok(PolicyReasonCode::ApprovalExpired);
|
|
}
|
|
if approval.status != ApprovalStatus::Pending {
|
|
return Ok(PolicyReasonCode::ApprovalReplayed);
|
|
}
|
|
let audit = audit_record(
|
|
&approval.context,
|
|
approval.origin,
|
|
&approval.principal,
|
|
BoundedText::new("policy.tool", approval.tool.clone())?,
|
|
None,
|
|
Some(id),
|
|
PolicyDisposition::Denied,
|
|
PolicyReasonCode::ApprovalNotGranted,
|
|
approval.cost,
|
|
approval.arguments_hash,
|
|
PolicyFinalOutcome::Denied,
|
|
)?;
|
|
self.emit(audit)?;
|
|
approval.status = ApprovalStatus::Denied;
|
|
Ok(PolicyReasonCode::ApprovalNotGranted)
|
|
}
|
|
|
|
/// Consumes the originating authorization so it cannot be replayed to
|
|
/// create more than one scheduler grant.
|
|
#[allow(clippy::needless_pass_by_value)]
|
|
pub fn issue_scheduler_grant(
|
|
&self,
|
|
action: AuthorizedAction,
|
|
remaining_runs: u64,
|
|
expires_at: u64,
|
|
now: u64,
|
|
) -> Result<SchedulerGrantId, PolicyReasonCode> {
|
|
if remaining_runs == 0
|
|
|| remaining_runs > self.limits.max_scheduler_runs_per_grant
|
|
|| expires_at <= now
|
|
|| expires_at > now.saturating_add(self.limits.max_scheduler_ttl_seconds)
|
|
|| !matches!(
|
|
action.receipt.origin,
|
|
OriginClass::AuthorizedIm | OriginClass::LocalOperator
|
|
)
|
|
|| !self
|
|
.tools
|
|
.get(action.receipt.tool.as_str())
|
|
.is_some_and(|tool| tool.scheduler_allowed)
|
|
{
|
|
return Err(PolicyReasonCode::SchedulerPrivilegeEscalation);
|
|
}
|
|
let mut state = lock(&self.state);
|
|
if state.scheduler_grants.len() >= self.limits.max_scheduler_grants {
|
|
return Err(PolicyReasonCode::GlobalBudgetExceeded);
|
|
}
|
|
let id = SchedulerGrantId(next_id(&mut state));
|
|
state.scheduler_grants.insert(
|
|
id,
|
|
SchedulerGrant {
|
|
id,
|
|
principal: action.receipt.principal.clone(),
|
|
tool: action.receipt.tool.as_str().to_owned(),
|
|
arguments_hash: action.receipt.arguments_hash,
|
|
expires_at,
|
|
remaining_runs,
|
|
},
|
|
);
|
|
Ok(id)
|
|
}
|
|
|
|
fn classify(
|
|
&self,
|
|
context: &PolicyRequestContext,
|
|
now: u64,
|
|
tool: Option<&str>,
|
|
arguments_hash: Option<&[u8; 32]>,
|
|
) -> Result<ClassifiedOrigin, PolicyReasonCode> {
|
|
match &context.origin.0 {
|
|
OriginKind::PublicChat(id) if *id == UUID::zero() => {
|
|
Err(PolicyReasonCode::OriginDenied)
|
|
}
|
|
OriginKind::PublicChat(id) => Ok(ClassifiedOrigin {
|
|
origin: OriginClass::PublicChat,
|
|
principal: PrincipalKey::Avatar(*id),
|
|
scheduler_grant: None,
|
|
}),
|
|
OriginKind::InstantMessage(id) if *id == UUID::zero() => {
|
|
Err(PolicyReasonCode::OriginDenied)
|
|
}
|
|
OriginKind::InstantMessage(id) => Ok(ClassifiedOrigin {
|
|
origin: if self.authorized_avatars.contains(id) {
|
|
OriginClass::AuthorizedIm
|
|
} else {
|
|
OriginClass::UnprivilegedIm
|
|
},
|
|
principal: PrincipalKey::Avatar(*id),
|
|
scheduler_grant: None,
|
|
}),
|
|
OriginKind::LocalOperator(principal) => Ok(ClassifiedOrigin {
|
|
origin: OriginClass::LocalOperator,
|
|
principal: PrincipalKey::Operator(principal.clone()),
|
|
scheduler_grant: None,
|
|
}),
|
|
OriginKind::InternalScheduler(id) => {
|
|
let state = lock(&self.state);
|
|
let grant = state
|
|
.scheduler_grants
|
|
.get(id)
|
|
.ok_or(PolicyReasonCode::SchedulerGrantUnknown)?;
|
|
if grant.id != *id || grant.expires_at <= now {
|
|
return Err(PolicyReasonCode::SchedulerGrantExpired);
|
|
}
|
|
if grant.remaining_runs == 0 {
|
|
return Err(PolicyReasonCode::SchedulerGrantExhausted);
|
|
}
|
|
if grant.remaining_runs > self.limits.max_scheduler_runs_per_grant {
|
|
return Err(PolicyReasonCode::SchedulerPrivilegeEscalation);
|
|
}
|
|
if tool != Some(grant.tool.as_str())
|
|
|| arguments_hash != Some(&grant.arguments_hash)
|
|
|| matches!(&grant.principal, PrincipalKey::Avatar(avatar) if !self.authorized_avatars.contains(avatar))
|
|
{
|
|
return Err(PolicyReasonCode::SchedulerPrivilegeEscalation);
|
|
}
|
|
Ok(ClassifiedOrigin {
|
|
origin: OriginClass::InternalScheduler,
|
|
principal: grant.principal.clone(),
|
|
scheduler_grant: Some(*id),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::needless_pass_by_value)] // Denial branches transfer the classified principal.
|
|
fn denied(
|
|
&self,
|
|
context: &PolicyRequestContext,
|
|
call: &ProposedToolCall,
|
|
principal: PrincipalKey,
|
|
origin: OriginClass,
|
|
arguments_hash: [u8; 32],
|
|
reason: PolicyReasonCode,
|
|
) -> Result<PolicyEvaluation, PolicyError> {
|
|
self.denied_with_cost(
|
|
context,
|
|
call,
|
|
principal,
|
|
origin,
|
|
arguments_hash,
|
|
ResourceCost::default(),
|
|
reason,
|
|
)
|
|
}
|
|
|
|
#[allow(clippy::needless_pass_by_value, clippy::too_many_arguments)]
|
|
fn denied_with_cost(
|
|
&self,
|
|
context: &PolicyRequestContext,
|
|
call: &ProposedToolCall,
|
|
principal: PrincipalKey,
|
|
origin: OriginClass,
|
|
arguments_hash: [u8; 32],
|
|
cost: ResourceCost,
|
|
reason: PolicyReasonCode,
|
|
) -> Result<PolicyEvaluation, PolicyError> {
|
|
self.emit(audit_record(
|
|
context,
|
|
origin,
|
|
&principal,
|
|
call.name.clone(),
|
|
None,
|
|
None,
|
|
PolicyDisposition::Denied,
|
|
reason,
|
|
cost,
|
|
arguments_hash,
|
|
PolicyFinalOutcome::Denied,
|
|
)?)?;
|
|
Ok(PolicyEvaluation {
|
|
disposition: PolicyDisposition::Denied,
|
|
reason,
|
|
approval_id: None,
|
|
authorization: None,
|
|
})
|
|
}
|
|
|
|
#[allow(clippy::needless_pass_by_value, clippy::too_many_arguments)]
|
|
fn approval_required(
|
|
&self,
|
|
context: &PolicyRequestContext,
|
|
call: &ProposedToolCall,
|
|
principal: PrincipalKey,
|
|
origin: OriginClass,
|
|
arguments_hash: [u8; 32],
|
|
cost: ResourceCost,
|
|
id: ApprovalId,
|
|
) -> Result<PolicyEvaluation, PolicyError> {
|
|
self.emit(audit_record(
|
|
context,
|
|
origin,
|
|
&principal,
|
|
call.name.clone(),
|
|
None,
|
|
Some(id),
|
|
PolicyDisposition::ApprovalRequired,
|
|
PolicyReasonCode::ApprovalRequired,
|
|
cost,
|
|
arguments_hash,
|
|
PolicyFinalOutcome::ApprovalRequired,
|
|
)?)?;
|
|
Ok(PolicyEvaluation {
|
|
disposition: PolicyDisposition::ApprovalRequired,
|
|
reason: PolicyReasonCode::ApprovalRequired,
|
|
approval_id: Some(id),
|
|
authorization: None,
|
|
})
|
|
}
|
|
|
|
fn emit(&self, record: PolicyAuditRecord) -> Result<(), PolicyError> {
|
|
self.audit
|
|
.emit(record)
|
|
.map_err(|_| PolicyError::AuditUnavailable)
|
|
}
|
|
|
|
fn emit_outcome(
|
|
&self,
|
|
receipt: &ActionReceipt,
|
|
outcome: PolicyFinalOutcome,
|
|
) -> Result<(), PolicyError> {
|
|
self.emit(audit_record(
|
|
&receipt.context,
|
|
receipt.origin,
|
|
&receipt.principal,
|
|
receipt.tool.clone(),
|
|
Some(receipt.authorization_id),
|
|
None,
|
|
PolicyDisposition::Allowed,
|
|
PolicyReasonCode::Allowed,
|
|
receipt.cost,
|
|
receipt.arguments_hash,
|
|
outcome,
|
|
)?)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct ClassifiedOrigin {
|
|
origin: OriginClass,
|
|
principal: PrincipalKey,
|
|
scheduler_grant: Option<SchedulerGrantId>,
|
|
}
|
|
|
|
fn origin_allows(tool: &PolicyTool, origin: OriginClass) -> bool {
|
|
if !tool.allowed_origins.contains(origin) {
|
|
return false;
|
|
}
|
|
match origin {
|
|
OriginClass::PublicChat => {
|
|
(tool.risk == Risk::ReadOnly && tool.capability == Capability::Informational)
|
|
|| tool.capability == Capability::PublicLslRequest
|
|
}
|
|
OriginClass::UnprivilegedIm => {
|
|
tool.risk == Risk::ReadOnly && tool.capability == Capability::Informational
|
|
}
|
|
OriginClass::AuthorizedIm | OriginClass::LocalOperator => true,
|
|
OriginClass::InternalScheduler => tool.scheduler_allowed,
|
|
}
|
|
}
|
|
|
|
fn rotate_budgets(state: &mut PolicyState, limits: BudgetLimits, now: u64) {
|
|
if state.window_started_at == 0
|
|
|| now
|
|
>= state
|
|
.window_started_at
|
|
.saturating_add(limits.window_seconds)
|
|
{
|
|
state.window_started_at = now;
|
|
state.global_used = ResourceCost::default();
|
|
state.principal_used.clear();
|
|
}
|
|
}
|
|
|
|
fn safe_budget(value: ResourceCost) -> bool {
|
|
value.tool_calls > 0
|
|
&& value.tool_calls <= MAX_BUDGET_TOOL_CALLS
|
|
&& value.linden_dollars == 0
|
|
&& value.upload_bytes <= MAX_BUDGET_UPLOAD_BYTES
|
|
&& value.inventory_operations <= MAX_BUDGET_INVENTORY_OPERATIONS
|
|
&& value.movement_millimeters <= MAX_BUDGET_MOVEMENT_MILLIMETERS
|
|
&& value.build_prims <= MAX_BUDGET_BUILD_PRIMS
|
|
}
|
|
|
|
fn next_id(state: &mut PolicyState) -> u64 {
|
|
let id = state.next_id;
|
|
state.next_id = state.next_id.saturating_add(1);
|
|
id
|
|
}
|
|
|
|
fn fallback_principal(context: &PolicyRequestContext) -> PrincipalKey {
|
|
match &context.origin.0 {
|
|
OriginKind::PublicChat(id) | OriginKind::InstantMessage(id) => PrincipalKey::Avatar(*id),
|
|
OriginKind::LocalOperator(principal) => PrincipalKey::Operator(principal.clone()),
|
|
OriginKind::InternalScheduler(_) => PrincipalKey::Operator(
|
|
AuthenticatedPrincipal::from_authenticated_control("invalid-scheduler")
|
|
.expect("static principal is valid"),
|
|
),
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn audit_record(
|
|
context: &PolicyRequestContext,
|
|
origin: OriginClass,
|
|
principal: &PrincipalKey,
|
|
tool: BoundedText<MAX_IDENTIFIER_BYTES>,
|
|
authorization_id: Option<u64>,
|
|
approval_id: Option<ApprovalId>,
|
|
disposition: PolicyDisposition,
|
|
reason: PolicyReasonCode,
|
|
applied_budget: ResourceCost,
|
|
arguments_hash: [u8; 32],
|
|
final_outcome: PolicyFinalOutcome,
|
|
) -> Result<PolicyAuditRecord, PolicyError> {
|
|
Ok(PolicyAuditRecord {
|
|
recorded_unix_millis: SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map_or(0, |duration| {
|
|
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
|
|
}),
|
|
authorization_id,
|
|
approval_id,
|
|
origin,
|
|
origin_avatar_id: principal.avatar_id(),
|
|
principal: BoundedText::new("policy.audit.principal", principal.audit_label())?,
|
|
session_id: context.session_id.clone(),
|
|
correlation_id: context.correlation_id.clone(),
|
|
tool,
|
|
disposition,
|
|
reason,
|
|
applied_budget,
|
|
arguments_hash: BoundedText::new("policy.audit.arguments_hash", hex_hash(arguments_hash))?,
|
|
final_outcome,
|
|
})
|
|
}
|
|
|
|
fn hash_arguments(arguments: &Value) -> Result<[u8; 32], PolicyError> {
|
|
let canonical = canonical_value(arguments);
|
|
let bytes = serde_json::to_vec(&canonical).map_err(|_| PolicyError::InvalidRegistration)?;
|
|
Ok(Sha256::digest(bytes).into())
|
|
}
|
|
|
|
fn canonical_value(value: &Value) -> Value {
|
|
match value {
|
|
Value::Object(object) => {
|
|
let sorted = object
|
|
.iter()
|
|
.map(|(key, value)| (key.clone(), canonical_value(value)))
|
|
.collect::<BTreeMap<_, _>>();
|
|
Value::Object(sorted.into_iter().collect::<Map<_, _>>())
|
|
}
|
|
Value::Array(values) => Value::Array(values.iter().map(canonical_value).collect()),
|
|
_ => value.clone(),
|
|
}
|
|
}
|
|
|
|
fn hex_hash(hash: [u8; 32]) -> String {
|
|
use std::fmt::Write as _;
|
|
let mut output = String::with_capacity(64);
|
|
for byte in hash {
|
|
write!(output, "{byte:02x}").expect("writing to String cannot fail");
|
|
}
|
|
output
|
|
}
|
|
|
|
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
|
mutex
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
}
|
|
|
|
fn policy_identifier(
|
|
field: &'static str,
|
|
value: impl Into<String>,
|
|
) -> Result<BoundedText<MAX_IDENTIFIER_BYTES>, PolicyError> {
|
|
let value = BoundedText::new(field, value)?;
|
|
if !value.chars().all(|character| {
|
|
character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-' | ':')
|
|
}) {
|
|
return Err(PolicyError::InvalidContext);
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum UntrustedSource {
|
|
PublicChat,
|
|
InstantMessage,
|
|
InventoryMetadata,
|
|
ObjectText,
|
|
ParcelData,
|
|
WebResponse,
|
|
LlmOutput,
|
|
GeneratedScript,
|
|
}
|
|
|
|
/// Bounded untrusted text that can only be rendered as a labelled JSON data
|
|
/// record. It never contributes system instructions or tool registrations.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct UntrustedData {
|
|
source: UntrustedSource,
|
|
body: BoundedText<MAX_MESSAGE_BYTES>,
|
|
}
|
|
|
|
impl UntrustedData {
|
|
pub fn new(source: UntrustedSource, body: impl Into<String>) -> Result<Self, PolicyError> {
|
|
Ok(Self {
|
|
source,
|
|
body: BoundedText::new("policy.untrusted_data", body)?,
|
|
})
|
|
}
|
|
|
|
pub fn render_json_record(&self) -> Result<BoundedText<MAX_MESSAGE_BYTES>, PolicyError> {
|
|
let source = match self.source {
|
|
UntrustedSource::PublicChat => "public_chat",
|
|
UntrustedSource::InstantMessage => "instant_message",
|
|
UntrustedSource::InventoryMetadata => "inventory_metadata",
|
|
UntrustedSource::ObjectText => "object_text",
|
|
UntrustedSource::ParcelData => "parcel_data",
|
|
UntrustedSource::WebResponse => "web_response",
|
|
UntrustedSource::LlmOutput => "llm_output",
|
|
UntrustedSource::GeneratedScript => "generated_script",
|
|
};
|
|
let render = |text: &str, truncated: bool| {
|
|
serde_json::to_string(&serde_json::json!({
|
|
"trust":"untrusted_data_only",
|
|
"source":source,
|
|
"truncated":truncated,
|
|
"text":text
|
|
}))
|
|
.map_err(|_| PolicyError::InvalidRegistration)
|
|
};
|
|
let complete = render(self.body.as_str(), false)?;
|
|
if complete.len() <= MAX_MESSAGE_BYTES {
|
|
return Ok(BoundedText::new("policy.untrusted_record", complete)?);
|
|
}
|
|
let boundaries = self
|
|
.body
|
|
.char_indices()
|
|
.map(|(index, _)| index)
|
|
.chain(std::iter::once(self.body.len()))
|
|
.collect::<Vec<_>>();
|
|
let mut low = 0;
|
|
let mut high = boundaries.len() - 1;
|
|
while low < high {
|
|
let middle = low + (high - low).div_ceil(2);
|
|
if render(&self.body[..boundaries[middle]], true)?.len() <= MAX_MESSAGE_BYTES {
|
|
low = middle;
|
|
} else {
|
|
high = middle - 1;
|
|
}
|
|
}
|
|
let value = render(&self.body[..boundaries[low]], true)?;
|
|
Ok(BoundedText::new("policy.untrusted_record", value)?)
|
|
}
|
|
}
|
|
|
|
/// The only production bridge from the generic tool loop to an action backend.
|
|
pub struct PolicyToolExecutor {
|
|
gateway: Arc<PolicyGateway>,
|
|
backend: Arc<dyn AuthorizedToolBackend>,
|
|
context: PolicyRequestContext,
|
|
approvals: BTreeMap<String, ApprovalId>,
|
|
now: Arc<dyn Fn() -> u64 + Send + Sync>,
|
|
}
|
|
|
|
impl fmt::Debug for PolicyToolExecutor {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter
|
|
.debug_struct("PolicyToolExecutor")
|
|
.field("gateway", &self.gateway)
|
|
.field("context", &self.context)
|
|
.field("approval_count", &self.approvals.len())
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
impl PolicyToolExecutor {
|
|
pub fn new(
|
|
gateway: Arc<PolicyGateway>,
|
|
backend: Arc<dyn AuthorizedToolBackend>,
|
|
context: PolicyRequestContext,
|
|
approvals: BTreeMap<String, ApprovalId>,
|
|
now: Arc<dyn Fn() -> u64 + Send + Sync>,
|
|
) -> Result<Self, PolicyError> {
|
|
if approvals.len() > crate::types::MAX_TOOL_CALLS
|
|
|| approvals
|
|
.keys()
|
|
.any(|call_id| call_id.is_empty() || call_id.len() > MAX_IDENTIFIER_BYTES)
|
|
{
|
|
return Err(PolicyError::UnsafeLimits);
|
|
}
|
|
Ok(Self {
|
|
gateway,
|
|
backend,
|
|
context,
|
|
approvals,
|
|
now,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl ToolExecutor for PolicyToolExecutor {
|
|
fn execute<'a>(
|
|
&'a self,
|
|
_definition: &'a ToolDefinition,
|
|
call: &'a ProposedToolCall,
|
|
arguments: &'a Value,
|
|
cancellation: &'a CancellationToken,
|
|
) -> ToolFuture<'a> {
|
|
Box::pin(async move {
|
|
let approval = self.approvals.get(call.call_id.as_str()).copied();
|
|
let Ok(evaluation) =
|
|
self.gateway
|
|
.evaluate(&self.context, call, arguments, approval, (self.now)())
|
|
else {
|
|
return fixed_failure("policy gateway failed closed");
|
|
};
|
|
let Some(action) = evaluation.into_authorization() else {
|
|
return fixed_rejection("policy denied or requires approval");
|
|
};
|
|
let receipt = action.receipt.clone();
|
|
let result = self.backend.apply(action, cancellation.clone()).await;
|
|
match result {
|
|
Ok(ToolCallOutcome::Completed { call_id, result })
|
|
if call_id == receipt.call_id =>
|
|
{
|
|
if self
|
|
.gateway
|
|
.emit_outcome(&receipt, PolicyFinalOutcome::Completed)
|
|
.is_err()
|
|
{
|
|
fixed_failure("policy outcome audit failed")
|
|
} else {
|
|
ToolExecution::Completed(result)
|
|
}
|
|
}
|
|
Ok(ToolCallOutcome::Rejected { call_id, reason }) if call_id == receipt.call_id => {
|
|
if self
|
|
.gateway
|
|
.emit_outcome(&receipt, PolicyFinalOutcome::Rejected)
|
|
.is_err()
|
|
{
|
|
fixed_failure("policy outcome audit failed")
|
|
} else {
|
|
ToolExecution::Rejected(
|
|
BoundedText::new("policy.backend_rejection", reason.as_str())
|
|
.expect("observable detail fits message bound"),
|
|
)
|
|
}
|
|
}
|
|
Ok(_) | Err(_)
|
|
if receipt.mutating && receipt.idempotency == Idempotency::NonIdempotent =>
|
|
{
|
|
let _ = self
|
|
.gateway
|
|
.emit_outcome(&receipt, PolicyFinalOutcome::AmbiguousMutation);
|
|
ToolExecution::AmbiguousMutation
|
|
}
|
|
Ok(_) | Err(_) => {
|
|
let _ = self
|
|
.gateway
|
|
.emit_outcome(&receipt, PolicyFinalOutcome::Failed);
|
|
fixed_failure("authorized tool backend failed")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
fn fixed_rejection(message: &'static str) -> ToolExecution {
|
|
ToolExecution::Rejected(
|
|
BoundedText::<MAX_MESSAGE_BYTES>::new("policy.rejection", message)
|
|
.expect("fixed rejection is bounded"),
|
|
)
|
|
}
|
|
|
|
fn fixed_failure(message: &'static str) -> ToolExecution {
|
|
ToolExecution::Failed(
|
|
BoundedText::<MAX_MESSAGE_BYTES>::new("policy.failure", message)
|
|
.expect("fixed failure is bounded"),
|
|
)
|
|
}
|