M1: tool trait, permission service, config, Provider trait, engine loop
Adds the Tool trait, config loading/schema, the Provider trait plus an Llm event surface, a permission service, and the core engine loop with a doom-loop guard and retry scaffolding. The processor drives a text/tool-call/final-text turn against a Provider, laying the groundwork for the MockProvider integration test and the real Anthropic path.
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use ulid::Ulid;
|
||||
|
||||
use crate::event::{AppEvent, EventBus, PermissionRequest};
|
||||
use crate::types::SessionId;
|
||||
|
||||
use super::rule::{evaluate, Action, Rule, Ruleset};
|
||||
|
||||
pub struct AskInput {
|
||||
pub permission: String,
|
||||
/// Pattern evaluated against the ruleset stack, and granted on a `Once` reply.
|
||||
pub pattern: String,
|
||||
/// Coarser pattern granted (as an `Allow` rule on `session.extra_rules`) on an `Always` reply.
|
||||
pub always_pattern: String,
|
||||
pub metadata: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PermissionReply {
|
||||
Once,
|
||||
Always,
|
||||
Reject,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum AskError {
|
||||
#[error("permission denied by ruleset")]
|
||||
Denied,
|
||||
#[error("permission rejected by user")]
|
||||
Rejected,
|
||||
#[error("cancelled")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AskDecision {
|
||||
/// Ruleset allowed it outright, or the user replied `Once`.
|
||||
Allowed,
|
||||
/// User replied `Always` — caller persists this rule onto `session.extra_rules`.
|
||||
AllowedAlways(Rule),
|
||||
}
|
||||
|
||||
/// Port of opencode's permission/index.ts ask flow: evaluate the ruleset stack first;
|
||||
/// only fall through to a blocking, event-published ask when the verdict is `Ask`.
|
||||
pub struct PermissionService {
|
||||
bus: EventBus,
|
||||
pending: Mutex<HashMap<String, oneshot::Sender<PermissionReply>>>,
|
||||
}
|
||||
|
||||
impl PermissionService {
|
||||
pub fn new(bus: EventBus) -> Self {
|
||||
Self {
|
||||
bus,
|
||||
pending: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ask(
|
||||
&self,
|
||||
session_id: &SessionId,
|
||||
stack: &[&Ruleset],
|
||||
input: AskInput,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<AskDecision, AskError> {
|
||||
match evaluate(stack, &input.permission, &input.pattern) {
|
||||
Action::Allow => Ok(AskDecision::Allowed),
|
||||
Action::Deny => Err(AskError::Denied),
|
||||
Action::Ask => self.ask_user(session_id, input, cancel).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bypasses ruleset evaluation entirely — used by the doom-loop guard, which must ask
|
||||
/// regardless of any `Allow` rule.
|
||||
pub async fn force_ask(
|
||||
&self,
|
||||
session_id: &SessionId,
|
||||
input: AskInput,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<AskDecision, AskError> {
|
||||
self.ask_user(session_id, input, cancel).await
|
||||
}
|
||||
|
||||
async fn ask_user(
|
||||
&self,
|
||||
session_id: &SessionId,
|
||||
input: AskInput,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<AskDecision, AskError> {
|
||||
let id = Ulid::new().to_string();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pending.lock().unwrap().insert(id.clone(), tx);
|
||||
|
||||
self.bus.publish(AppEvent::PermissionAsked {
|
||||
request: PermissionRequest {
|
||||
id: id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
permission: input.permission.clone(),
|
||||
pattern: input.pattern.clone(),
|
||||
always_pattern: input.always_pattern.clone(),
|
||||
metadata: input.metadata.clone(),
|
||||
},
|
||||
});
|
||||
|
||||
let reply = tokio::select! {
|
||||
reply = rx => reply.map_err(|_| AskError::Cancelled)?,
|
||||
_ = cancel.cancelled() => {
|
||||
self.pending.lock().unwrap().remove(&id);
|
||||
return Err(AskError::Cancelled);
|
||||
}
|
||||
};
|
||||
|
||||
self.bus.publish(AppEvent::PermissionResolved { id });
|
||||
|
||||
match reply {
|
||||
PermissionReply::Once => Ok(AskDecision::Allowed),
|
||||
PermissionReply::Always => Ok(AskDecision::AllowedAlways(Rule {
|
||||
permission: input.permission,
|
||||
pattern: input.always_pattern,
|
||||
action: Action::Allow,
|
||||
})),
|
||||
PermissionReply::Reject => Err(AskError::Rejected),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a pending ask (called by a frontend: TUI keypress, auto-approve stub, ...).
|
||||
/// Returns `false` if `id` had already been resolved or never existed.
|
||||
pub fn reply(&self, id: &str, reply: PermissionReply) -> bool {
|
||||
match self.pending.lock().unwrap().remove(id) {
|
||||
Some(tx) => tx.send(reply).is_ok(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-approves every ask as `Once` — the M1..M2 stand-in for a real TUI permission modal.
|
||||
pub fn spawn_auto_approve(bus: EventBus, service: std::sync::Arc<PermissionService>) {
|
||||
let mut rx = bus.subscribe();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(event) = rx.recv().await {
|
||||
if let AppEvent::PermissionAsked { request } = event {
|
||||
service.reply(&request.id, PermissionReply::Once);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rule(permission: &str, pattern: &str, action: Action) -> Rule {
|
||||
Rule {
|
||||
permission: permission.to_string(),
|
||||
pattern: pattern.to_string(),
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allow_rule_short_circuits_without_asking() {
|
||||
let service = PermissionService::new(EventBus::new());
|
||||
let rules: Ruleset = vec![rule("bash", "ls*", Action::Allow)];
|
||||
let outcome = service
|
||||
.ask(
|
||||
&SessionId::new(),
|
||||
&[&rules],
|
||||
AskInput {
|
||||
permission: "bash".into(),
|
||||
pattern: "ls -la".into(),
|
||||
always_pattern: "ls *".into(),
|
||||
metadata: serde_json::Value::Null,
|
||||
},
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, AskDecision::Allowed));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deny_rule_short_circuits_to_denied_error() {
|
||||
let service = PermissionService::new(EventBus::new());
|
||||
let rules: Ruleset = vec![rule("edit", "*.lock", Action::Deny)];
|
||||
let err = service
|
||||
.ask(
|
||||
&SessionId::new(),
|
||||
&[&rules],
|
||||
AskInput {
|
||||
permission: "edit".into(),
|
||||
pattern: "Cargo.lock".into(),
|
||||
always_pattern: "Cargo.lock".into(),
|
||||
metadata: serde_json::Value::Null,
|
||||
},
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err, AskError::Denied);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ask_publishes_event_and_waits_for_reply() {
|
||||
let bus = EventBus::new();
|
||||
let mut sub = bus.subscribe();
|
||||
let service = std::sync::Arc::new(PermissionService::new(bus));
|
||||
let svc = service.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
svc.ask(
|
||||
&SessionId::new(),
|
||||
&[],
|
||||
AskInput {
|
||||
permission: "bash".into(),
|
||||
pattern: "rm -rf /".into(),
|
||||
always_pattern: "rm *".into(),
|
||||
metadata: serde_json::Value::Null,
|
||||
},
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
let event = sub.recv().await.unwrap();
|
||||
let id = match event {
|
||||
AppEvent::PermissionAsked { request } => request.id,
|
||||
_ => panic!("expected PermissionAsked"),
|
||||
};
|
||||
assert!(service.reply(&id, PermissionReply::Once));
|
||||
|
||||
let outcome = handle.await.unwrap().unwrap();
|
||||
assert!(matches!(outcome, AskDecision::Allowed));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn always_reply_yields_rule_to_persist() {
|
||||
let bus = EventBus::new();
|
||||
let mut sub = bus.subscribe();
|
||||
let service = std::sync::Arc::new(PermissionService::new(bus));
|
||||
let svc = service.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
svc.ask(
|
||||
&SessionId::new(),
|
||||
&[],
|
||||
AskInput {
|
||||
permission: "bash".into(),
|
||||
pattern: "git push origin main".into(),
|
||||
always_pattern: "git push*".into(),
|
||||
metadata: serde_json::Value::Null,
|
||||
},
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
let request = match sub.recv().await.unwrap() {
|
||||
AppEvent::PermissionAsked { request } => request,
|
||||
_ => panic!("expected PermissionAsked"),
|
||||
};
|
||||
service.reply(&request.id, PermissionReply::Always);
|
||||
|
||||
let outcome = handle.await.unwrap().unwrap();
|
||||
match outcome {
|
||||
AskDecision::AllowedAlways(rule) => {
|
||||
assert_eq!(rule.permission, "bash");
|
||||
assert_eq!(rule.pattern, "git push*");
|
||||
assert_eq!(rule.action, Action::Allow);
|
||||
}
|
||||
_ => panic!("expected AllowedAlways"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reject_reply_yields_rejected_error() {
|
||||
let bus = EventBus::new();
|
||||
let mut sub = bus.subscribe();
|
||||
let service = std::sync::Arc::new(PermissionService::new(bus));
|
||||
let svc = service.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
svc.ask(
|
||||
&SessionId::new(),
|
||||
&[],
|
||||
AskInput {
|
||||
permission: "bash".into(),
|
||||
pattern: "rm -rf /".into(),
|
||||
always_pattern: "rm *".into(),
|
||||
metadata: serde_json::Value::Null,
|
||||
},
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
let request = match sub.recv().await.unwrap() {
|
||||
AppEvent::PermissionAsked { request } => request,
|
||||
_ => panic!("expected PermissionAsked"),
|
||||
};
|
||||
service.reply(&request.id, PermissionReply::Reject);
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err();
|
||||
assert_eq!(err, AskError::Rejected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_aborts_a_pending_ask() {
|
||||
let bus = EventBus::new();
|
||||
let service = std::sync::Arc::new(PermissionService::new(bus));
|
||||
let cancel = CancellationToken::new();
|
||||
let cancel2 = cancel.clone();
|
||||
let svc = service.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
svc.ask(
|
||||
&SessionId::new(),
|
||||
&[],
|
||||
AskInput {
|
||||
permission: "bash".into(),
|
||||
pattern: "rm -rf /".into(),
|
||||
always_pattern: "rm *".into(),
|
||||
metadata: serde_json::Value::Null,
|
||||
},
|
||||
&cancel2,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
cancel.cancel();
|
||||
let err = handle.await.unwrap().unwrap_err();
|
||||
assert_eq!(err, AskError::Cancelled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_approve_resolves_asks_as_once() {
|
||||
let bus = EventBus::new();
|
||||
let service = std::sync::Arc::new(PermissionService::new(bus.clone()));
|
||||
spawn_auto_approve(bus, service.clone());
|
||||
|
||||
let outcome = service
|
||||
.ask(
|
||||
&SessionId::new(),
|
||||
&[],
|
||||
AskInput {
|
||||
permission: "bash".into(),
|
||||
pattern: "ls".into(),
|
||||
always_pattern: "ls *".into(),
|
||||
metadata: serde_json::Value::Null,
|
||||
},
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, AskDecision::Allowed));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user