Adds the task tool and subagent spawner in harness-tools, and wires spawning/aliasing/reuse and depth limits through harness-app and the job board, so an orchestrator session can launch and track child sessions.
378 lines
12 KiB
Rust
378 lines
12 KiB
Rust
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, evaluate_intersected, 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,
|
|
}
|
|
}
|
|
|
|
/// Like [`ask`](Self::ask), but for a subagent: the verdict is the more restrictive of
|
|
/// the `parent_stack` (rules inherited from the spawning chain) and `child_stack` (the
|
|
/// subagent's own rules). Used so a child can never widen what its parent forbids.
|
|
pub async fn ask_intersected(
|
|
&self,
|
|
session_id: &SessionId,
|
|
parent_stack: &[&Ruleset],
|
|
child_stack: &[&Ruleset],
|
|
input: AskInput,
|
|
cancel: &CancellationToken,
|
|
) -> Result<AskDecision, AskError> {
|
|
match evaluate_intersected(parent_stack, child_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));
|
|
}
|
|
}
|