use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
use scv_core::{AgentError, ApprovalGate, ApprovalRequest, ToolRisk};
use scv_protocol::ServerEvent;
use tokio::sync::{Mutex, oneshot};
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::{
config::PolicyMode,
confirm::Confirmer,
outbound::{OutboundSender, send_turn_event},
session::{TurnMeta, next_seq},
};
#[derive(Default)]
pub(crate) struct ApprovalBroker {
pub(crate) pending: Mutex<HashMap<String, oneshot::Sender<bool>>>,
}
impl ApprovalBroker {
pub(crate) async fn insert(&self, id: String, sender: oneshot::Sender<bool>) {
self.pending.lock().await.insert(id, sender);
}
pub(crate) async fn remove(&self, id: &str) {
self.pending.lock().await.remove(id);
}
pub(crate) async fn resolve(&self, id: &str, approved: bool) -> bool {
let sender = self.pending.lock().await.remove(id);
sender.is_some_and(|sender| sender.send(approved).is_ok())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Policy {
pub(crate) mode: PolicyMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Decision {
Allow,
Deny,
AskClient,
AskOwner,
}
const FORCE_PUSH_MAIN: &str = "Force-pushing main is never allowed";
const CARGO_PUBLISH: &str = "A direct cargo publish needs the owner's yes (feature-flow's publish \
script is the path that counts crates.io's daily limit)";
pub(crate) fn decide(policy: Policy, request: &ApprovalRequest) -> Decision {
if let Some((decision, _)) = gate(request) {
return decision;
}
let read_only = request.risk == ToolRisk::ReadOnly;
match policy.mode {
PolicyMode::Auto => Decision::Allow,
PolicyMode::OnRisk | PolicyMode::ReadOnly if read_only => Decision::Allow,
PolicyMode::ReadOnly => Decision::Deny,
PolicyMode::OnRisk | PolicyMode::Always => Decision::AskClient,
}
}
fn gate(request: &ApprovalRequest) -> Option<(Decision, &'static str)> {
let commands = simple_commands(request.command.as_deref()?);
if commands.iter().any(|words| forces_main(words)) {
return Some((Decision::Deny, FORCE_PUSH_MAIN));
}
commands
.iter()
.any(|words| cargo_publish(words))
.then_some((Decision::AskOwner, CARGO_PUBLISH))
}
fn simple_commands(command: &str) -> Vec<Vec<String>> {
let mut commands = vec![Vec::new()];
let mut word = String::new();
let mut in_word = false;
let mut quote: Option<char> = None;
let mut chars = command.chars();
let end_word = |commands: &mut Vec<Vec<String>>, word: &mut String, in_word: &mut bool| {
if std::mem::take(in_word) {
commands
.last_mut()
.expect("one command at least")
.push(std::mem::take(word));
}
};
while let Some(character) = chars.next() {
match (quote, character) {
(Some(open), _) if character == open => quote = None,
(Some('"'), '\\') => word.extend(chars.next()),
(Some(_), _) => word.push(character),
(None, '\'' | '"') => {
quote = Some(character);
in_word = true;
}
(None, '\\') => {
word.extend(chars.next());
in_word = true;
}
(None, ';' | '&' | '|' | '(' | ')' | '\n') => {
end_word(&mut commands, &mut word, &mut in_word);
commands.push(Vec::new());
}
(None, _) if character.is_whitespace() => {
end_word(&mut commands, &mut word, &mut in_word);
}
(None, _) => {
word.push(character);
in_word = true;
}
}
}
end_word(&mut commands, &mut word, &mut in_word);
commands.retain(|words| !words.is_empty());
commands
}
fn arguments_of<'a>(words: &'a [String], program: &str) -> Option<&'a [String]> {
let at = words
.iter()
.position(|word| word.rsplit('/').next() == Some(program))?;
Some(&words[at + 1..])
}
fn forces_main(words: &[String]) -> bool {
let Some(arguments) = arguments_of(words, "git") else {
return false;
};
let mut rest = arguments.iter();
let subcommand = loop {
match rest.next().map(String::as_str) {
Some("-C" | "-c" | "--git-dir" | "--work-tree" | "--namespace") => {
rest.next();
}
Some(option) if option.starts_with('-') => {}
Some(subcommand) => break subcommand,
None => return false,
}
};
if subcommand != "push" {
return false;
}
let mut force = false;
let mut positional = Vec::new();
while let Some(argument) = rest.next() {
match argument.as_str() {
"--" => {
positional.extend(rest.by_ref());
break;
}
"-f" | "--force" => force = true,
option if option.starts_with("--force-with-lease") => force = true,
"-o" | "--push-option" | "--repo" | "--receive-pack" | "--exec" => {
rest.next();
}
option if option.starts_with("--") => {}
option if option.starts_with('-') => {
force |= option.contains('f');
if option.ends_with('o') {
rest.next();
}
}
_ => positional.push(argument),
}
}
positional.iter().skip(1).any(|refspec| {
let (forced, refspec) = match refspec.strip_prefix('+') {
Some(refspec) => (true, refspec),
None => (force, refspec.as_str()),
};
let destination = refspec.rsplit(':').next().unwrap_or(refspec);
forced && matches!(destination, "main" | "refs/heads/main")
})
}
fn cargo_publish(words: &[String]) -> bool {
let Some(arguments) = arguments_of(words, "cargo") else {
return false;
};
let mut rest = arguments.iter();
loop {
match rest.next().map(String::as_str) {
Some("-Z" | "--config" | "-C" | "--color") => {
rest.next();
}
Some(option) if option.starts_with('-') || option.starts_with('+') => {}
Some(subcommand) => return subcommand == "publish",
None => return false,
}
}
}
fn owner_question(request: &ApprovalRequest) -> String {
let reason = gate(request).map_or("This needs the owner's yes", |(_, reason)| reason);
let summary: String = request.summary.chars().take(3000).collect();
format!("{reason}. An agent asks to run this:\n\n{summary}")
}
#[derive(Clone)]
pub(crate) struct OwnerQuestions {
pub(crate) confirmer: Option<Arc<Confirmer>>,
pub(crate) session: String,
}
impl OwnerQuestions {
pub(crate) async fn ask(
&self,
request: &ApprovalRequest,
cancellation: &CancellationToken,
) -> bool {
let Some(confirmer) = &self.confirmer else {
tracing::warn!(
"{} needs the owner's yes, which this server cannot ask for; denied",
request.name
);
return false;
};
confirmer
.ask_owner(&owner_question(request), &self.session, cancellation)
.await
}
}
pub(crate) struct ProtocolApprovalGate {
pub(crate) policy: Policy,
pub(crate) broker: Arc<ApprovalBroker>,
pub(crate) meta: TurnMeta,
pub(crate) output: OutboundSender,
pub(crate) workbench: Option<Arc<scv_tools::workbench::SessionHandle>>,
pub(crate) owner_at_client: bool,
pub(crate) owner: OwnerQuestions,
}
pub(crate) struct UnattendedGate {
pub(crate) policy: Policy,
pub(crate) client_approves_all: bool,
pub(crate) owner: OwnerQuestions,
}
#[async_trait]
impl ApprovalGate for UnattendedGate {
async fn approve(
&self,
request: ApprovalRequest,
cancellation: CancellationToken,
) -> Result<bool, AgentError> {
Ok(match decide(self.policy, &request) {
Decision::Allow => true,
Decision::Deny => false,
Decision::AskClient => self.client_approves_all,
Decision::AskOwner => self.owner.ask(&request, &cancellation).await,
})
}
}
pub(crate) struct PolicyFirstGate {
pub(crate) policy: Policy,
pub(crate) attended: Arc<dyn ApprovalGate>,
pub(crate) owner: OwnerQuestions,
}
#[async_trait]
impl ApprovalGate for PolicyFirstGate {
async fn approve(
&self,
request: ApprovalRequest,
cancellation: CancellationToken,
) -> Result<bool, AgentError> {
match decide(self.policy, &request) {
Decision::Allow => Ok(true),
Decision::Deny => Ok(false),
Decision::AskClient => self.attended.approve(request, cancellation).await,
Decision::AskOwner => Ok(self.owner.ask(&request, &cancellation).await),
}
}
}
#[async_trait]
impl ApprovalGate for ProtocolApprovalGate {
async fn approve(
&self,
mut request: ApprovalRequest,
cancellation: CancellationToken,
) -> Result<bool, AgentError> {
match decide(self.policy, &request) {
Decision::Allow => return Ok(true),
Decision::Deny => return Ok(false),
Decision::AskClient => {}
Decision::AskOwner if self.owner_at_client => {
if let Some((_, reason)) = gate(&request) {
request.summary = format!("{reason}. {}", request.summary);
}
}
Decision::AskOwner => return Ok(self.owner.ask(&request, &cancellation).await),
}
let approval_id = Uuid::new_v4().to_string();
let (sender, receiver) = oneshot::channel();
self.broker.insert(approval_id.clone(), sender).await;
let waiting = Waiting::new(self.workbench.as_ref(), &request.summary);
let event = ServerEvent::ApprovalRequested {
request_id: self.meta.request_id.clone(),
session_id: self.meta.session_id.clone(),
turn_id: self.meta.turn_id.clone(),
seq: next_seq(&self.meta.seq),
approval_id: approval_id.clone(),
call_id: request.call_id,
name: request.name,
risk: request.risk.as_str().into(),
cwd: request.cwd.display().to_string(),
summary: request.summary,
};
if let Err(error) = send_turn_event(
&self.output,
event,
self.meta.max_server_frame,
&cancellation,
)
.await
{
self.broker.remove(&approval_id).await;
return Err(error);
}
let answer = tokio::select! {
result = receiver => result.map_err(|_| AgentError::Cancelled),
() = cancellation.cancelled() => {
self.broker.remove(&approval_id).await;
Err(AgentError::Cancelled)
}
};
drop(waiting);
answer
}
}
struct Waiting<'a>(Option<&'a Arc<scv_tools::workbench::SessionHandle>>);
impl<'a> Waiting<'a> {
fn new(
registration: Option<&'a Arc<scv_tools::workbench::SessionHandle>>,
summary: &str,
) -> Self {
if let Some(registration) = registration {
let summary: String = summary.chars().take(200).collect();
registration.set_state(
scv_protocol::AgentState::AwaitingInput,
Some(format!("waiting for approval: {summary}")),
);
}
Self(registration)
}
}
impl Drop for Waiting<'_> {
fn drop(&mut self) {
if let Some(registration) = self.0 {
registration.set_state(scv_protocol::AgentState::Working, None);
}
}
}
#[cfg(test)]
mod tests;