Files
galaxy/crates/acp/src/permissions.rs
T

134 lines
4.6 KiB
Rust

use std::sync::Arc;
use agent_client_protocol::schema::v1::{
PermissionOptionId, PermissionOptionKind, RequestPermissionOutcome, RequestPermissionRequest,
SelectedPermissionOutcome, ToolKind,
};
use futures::future::{BoxFuture, FutureExt as _};
/// Galaxy permissions that may be granted to an ACP agent for one turn.
///
/// This deliberately grants only categories marked `AlwaysAllow` by the
/// active Galaxy execution profile. Interactive permissions remain denied
/// until Galaxy can surface the agent's permission choices in its own UI.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct AcpPermissionPolicy {
/// Permit file and data reads, including searches.
pub read: bool,
/// Permit edits, deletes, and moves.
pub edit: bool,
/// Permit command and code execution.
pub execute: bool,
/// Permit fetching external data.
pub fetch: bool,
/// Permit uncategorized tools, including MCP tools.
pub other: bool,
}
impl AcpPermissionPolicy {
fn allows(self, kind: ToolKind) -> bool {
match kind {
ToolKind::Read | ToolKind::Search => self.read,
ToolKind::Edit | ToolKind::Delete | ToolKind::Move => self.edit,
ToolKind::Execute => self.execute,
ToolKind::Think => true,
ToolKind::Fetch => self.fetch,
ToolKind::SwitchMode => false,
ToolKind::Other => self.other,
_ => false,
}
}
}
/// Information supplied to Galaxy's permission hook.
#[derive(Clone, Debug)]
pub struct PermissionContext {
/// The original ACP permission request.
pub request: RequestPermissionRequest,
/// Whether this turn was explicitly launched in Galaxy's autonomous
/// execution mode.
pub auto_approve: bool,
/// Category permissions inherited from the active Galaxy profile.
pub policy: AcpPermissionPolicy,
}
/// A decision returned by a [`PermissionHandler`].
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PermissionDecision {
/// Select the first one-shot (then persistent) allow option.
Allow,
/// Select the first one-shot (then persistent) reject option.
Deny,
/// Select a specific option advertised by the agent.
Select(PermissionOptionId),
/// Report that the permission interaction was cancelled.
Cancel,
}
/// Host hook used to resolve ACP permission requests.
pub trait PermissionHandler: Send + Sync {
/// Returns a permission decision without blocking the ACP dispatch loop.
fn decide(&self, context: PermissionContext) -> BoxFuture<'static, PermissionDecision>;
}
/// Safe default permission hook.
///
/// Requests are rejected unless the individual turn explicitly opts into
/// automatic approval.
#[derive(Debug, Default)]
pub struct DenyByDefaultPermissionHandler;
impl PermissionHandler for DenyByDefaultPermissionHandler {
fn decide(&self, context: PermissionContext) -> BoxFuture<'static, PermissionDecision> {
async move {
let kind = context.request.tool_call.fields.kind.unwrap_or_default();
if context.auto_approve || context.policy.allows(kind) {
PermissionDecision::Allow
} else {
PermissionDecision::Deny
}
}
.boxed()
}
}
pub(crate) fn outcome_for_decision(
request: &RequestPermissionRequest,
decision: PermissionDecision,
) -> RequestPermissionOutcome {
let selected = match decision {
PermissionDecision::Allow => request
.options
.iter()
.find(|option| option.kind == PermissionOptionKind::AllowOnce)
.map(|option| option.option_id.clone()),
PermissionDecision::Deny => request
.options
.iter()
.find(|option| option.kind == PermissionOptionKind::RejectOnce)
.or_else(|| {
request
.options
.iter()
.find(|option| option.kind == PermissionOptionKind::RejectAlways)
})
.map(|option| option.option_id.clone()),
PermissionDecision::Select(option_id) => request
.options
.iter()
.find(|option| option.option_id == option_id)
.map(|option| option.option_id.clone()),
PermissionDecision::Cancel => None,
};
selected.map_or(RequestPermissionOutcome::Cancelled, |option_id| {
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option_id))
})
}
pub(crate) type SharedPermissionHandler = Arc<dyn PermissionHandler>;
#[cfg(test)]
#[path = "permissions_tests.rs"]
mod tests;