1use crate::tool::{ApprovalLevel, ToolArgs, ToolCtx};
2use crate::trust::RiskKind;
3use std::collections::BTreeSet;
4
5pub enum ApprovalOutcome {
6 Approve {
7 authorization: Box<crate::permission::InvocationAuthorization>,
8 },
9 Deny {
10 reason: String,
11 },
12}
13
14pub fn level_str(level: ApprovalLevel) -> &'static str {
15 match level {
16 ApprovalLevel::Auto => "auto",
17 ApprovalLevel::Approve => "approve",
18 ApprovalLevel::Dangerous => "dangerous",
19 }
20}
21
22fn resolve_provenance(
23 ctx: &ToolCtx,
24 tool: Option<&dyn crate::tool::Tool>,
25 args: &ToolArgs,
26) -> Result<crate::permission::ResourceProvenance, String> {
27 match tool {
28 Some(tool) => tool
29 .invocation_provenance(args, ctx)
30 .map_err(|e| e.to_string()),
31 None => Ok(crate::permission::ResourceProvenance::none()),
32 }
33}
34
35fn intent_risks(
37 tier: crate::tool::Tier,
38 provenance: &crate::permission::ResourceProvenance,
39) -> BTreeSet<RiskKind> {
40 let mut risks = provenance.risks.clone();
41 let mut targets = provenance.authorized_targets().peekable();
42 let external_non_temp = provenance.is_external()
43 && (targets.peek().is_none()
44 || targets.any(|target| !crate::fs_access::is_temp_path(target)));
45 if external_non_temp {
46 risks.insert(RiskKind::WorkspaceExternal);
47 }
48 if tier == crate::tool::Tier::Four {
49 risks.insert(RiskKind::ProcessSpawn);
50 }
51 if provenance.network {
52 risks.insert(RiskKind::Network);
53 }
54 risks
55}
56
57fn args_digest(args_preview: &str) -> String {
58 blake3::hash(args_preview.as_bytes()).to_hex().to_string()
59}
60
61fn submit_to_broker(
62 ctx: &ToolCtx,
63 intent: crate::permission::PermissionIntent,
64 tier: crate::tool::Tier,
65) -> Result<crate::permission::SubmissionOutcome, String> {
66 let broker = ctx
67 .permission_broker
68 .as_ref()
69 .ok_or_else(|| "missing permission broker".to_string())?;
70 let identity = ctx
71 .flow_identity
72 .as_ref()
73 .ok_or_else(|| "missing flow identity".to_string())?;
74 let trust = ctx
75 .trust
76 .as_ref()
77 .ok_or_else(|| "missing trust snapshot".to_string())?;
78 let registry = ctx
81 .flow_registry
82 .as_ref()
83 .ok_or_else(|| "missing flow registry".to_string())?;
84 if !broker.is_for_registry(registry) {
85 return Err("permission broker and flow registry mismatch".into());
86 }
87 broker
88 .submit(
89 Some(identity.session_id.as_str()),
90 Some(&identity.run_id),
91 intent,
92 tier == crate::tool::Tier::Four,
93 trust,
94 )
95 .map_err(|error| error.to_string())
96}
97
98async fn defer_after_ancestor_timeout(
99 broker: &crate::permission::PermissionBroker,
100 request_id: &crate::permission::PermissionRequestId,
101 target_run_id: &crate::event::FlowRunId,
102) -> Result<bool, crate::permission::PermissionError> {
103 tokio::time::sleep(crate::permission::ANCESTOR_OFFER_TIMEOUT).await;
104 broker.defer_timed_out_target(request_id, target_run_id)
105}
106
107#[cfg(test)]
108fn settle_broker_request(
109 ctx: &ToolCtx,
110 request_id: &crate::permission::PermissionRequestId,
111 decision: crate::session::ApprovalDecision,
112) -> crate::session::ApprovalDecision {
113 let (Some(broker), Some(identity)) =
114 (ctx.permission_broker.as_ref(), ctx.flow_identity.as_ref())
115 else {
116 return crate::session::ApprovalDecision::Deny {
117 reason: "permission broker identity unavailable".into(),
118 };
119 };
120 let (action, grant_scope, reason) = match &decision {
121 crate::session::ApprovalDecision::Approve => (
122 crate::permission::PermissionAction::Approve,
123 Some(crate::permission::GrantScope::CurrentCall),
124 None,
125 ),
126 crate::session::ApprovalDecision::Deny { reason } => (
127 crate::permission::PermissionAction::Deny,
128 None,
129 Some(reason.clone()),
130 ),
131 };
132 let authority = crate::permission::DecisionAuthority::User(
133 broker.user_authority(identity.session_id.clone(), None),
134 );
135 match broker.resolve(request_id, &authority, action, grant_scope, reason) {
136 Ok(crate::permission::ResolveOutcome::Resolved(resolved)) if resolved.action == action => {
137 decision
138 }
139 Ok(_) | Err(crate::permission::PermissionError::AlreadyResolved) => {
140 crate::session::ApprovalDecision::Deny {
141 reason: "permission request was already settled".into(),
142 }
143 }
144 Err(error) => {
145 crate::notify!(warn, "permission resolve failed: {error}");
146 crate::session::ApprovalDecision::Deny {
147 reason: format!("permission resolve failed: {error}"),
148 }
149 }
150 }
151}
152
153pub async fn authorize_tool_invocation(
154 ctx: &ToolCtx,
155 id: &str,
156 name: &str,
157 call_args: &ToolArgs,
158 tool: &dyn crate::tool::Tool,
159) -> Result<ToolCtx, String> {
160 if tool.invocation_plane() == crate::tool::InvocationPlane::PermissionControl {
161 let broker = ctx
162 .permission_broker
163 .as_ref()
164 .ok_or_else(|| format!("{name}: permission broker is missing"))?;
165 let actor = ctx
166 .flow_identity
167 .as_ref()
168 .ok_or_else(|| format!("{name}: permission identity is missing"))?;
169 broker
170 .authenticate_control_actor(actor)
171 .map_err(|error| format!("{name}: permission control rejected the actor: {error}"))?;
172 return Ok(ctx.clone());
173 }
174 match request_approval(
175 ctx,
176 id,
177 name,
178 call_args,
179 tool.approval_level(call_args, ctx),
180 Some(tool),
181 )
182 .await
183 {
184 ApprovalOutcome::Approve { authorization } => Ok(ctx.authorized_for(*authorization)),
185 ApprovalOutcome::Deny { reason } => Err(reason),
186 }
187}
188
189pub async fn request_approval(
190 ctx: &ToolCtx,
191 id: &str,
192 name: &str,
193 call_args: &ToolArgs,
194 level: ApprovalLevel,
195 tool: Option<&dyn crate::tool::Tool>,
196) -> ApprovalOutcome {
197 request_approval_with_additional_risks(ctx, id, name, call_args, level, tool, []).await
198}
199
200pub async fn request_approval_with_additional_risks(
201 ctx: &ToolCtx,
202 id: &str,
203 name: &str,
204 call_args: &ToolArgs,
205 level: ApprovalLevel,
206 tool: Option<&dyn crate::tool::Tool>,
207 additional_risks: impl IntoIterator<Item = RiskKind>,
208) -> ApprovalOutcome {
209 let provenance = match resolve_provenance(ctx, tool, call_args) {
210 Ok(provenance) => provenance,
211 Err(reason) => {
215 return ApprovalOutcome::Deny {
216 reason: format!("{name}: blocked — {reason}"),
217 };
218 }
219 };
220 let permit_provenance = provenance.clone();
221 let permit = |request_id, execution_boundary| {
222 crate::permission::InvocationAuthorization::new(
223 request_id,
224 id,
225 name,
226 permit_provenance.clone(),
227 execution_boundary,
228 )
229 };
230 if ctx.flow_run_id.is_none() {
231 return ApprovalOutcome::Deny {
232 reason: format!("{name}: blocked — missing run identity"),
233 };
234 }
235 let args_preview: String = format!("{:?}", call_args.named)
236 .chars()
237 .take(4000)
238 .collect();
239 let preview = if level == ApprovalLevel::Auto {
240 None
241 } else {
242 match tool {
243 Some(t) => t.preview_call(call_args, ctx).await,
244 None => None,
245 }
246 };
247 let tier = tool.map(|t| t.tier()).unwrap_or(crate::tool::Tier::Zero);
248 let mut risks = intent_risks(tier, &provenance);
249 risks.extend(additional_risks);
250 let intent = crate::permission::PermissionIntent {
251 tool_use_id: id.to_string(),
252 tool_name: name.to_string(),
253 call_intent: ctx.call_intent.clone(),
254 tier,
255 risks,
256 args_digest: args_digest(&args_preview),
257 preview: preview.clone(),
258 provenance,
259 };
260 let brokered = match submit_to_broker(ctx, intent, tier) {
261 Ok(outcome) => outcome,
262 Err(error) => {
263 return ApprovalOutcome::Deny {
264 reason: format!("{name}: permission broker rejected the request: {error}"),
265 };
266 }
267 };
268 let pending_permission = match brokered {
269 crate::permission::SubmissionOutcome::Immediate(immediate) => {
270 use crate::permission::ImmediateAuthorization;
271 let request_id = immediate.request.request_id.clone();
272 match immediate.authorization {
273 ImmediateAuthorization::Unrestricted => {
274 return ApprovalOutcome::Approve {
275 authorization: Box::new(permit(
276 request_id.clone(),
277 crate::permission::ExecutionBoundary::Direct,
278 )),
279 };
280 }
281 ImmediateAuthorization::Auto { execution_boundary } => {
282 return ApprovalOutcome::Approve {
283 authorization: Box::new(permit(request_id.clone(), execution_boundary)),
284 };
285 }
286 ImmediateAuthorization::Granted { grant } => {
287 return ApprovalOutcome::Approve {
288 authorization: Box::new(permit(
289 request_id.clone(),
290 grant.execution_boundary,
291 )),
292 };
293 }
294 ImmediateAuthorization::Denied { reason } => {
295 let reason = format!("{name}: {reason}");
296 return ApprovalOutcome::Deny { reason };
297 }
298 }
299 }
300 crate::permission::SubmissionOutcome::Pending(pending) => Some(pending),
301 };
302 let mut pending_permission = pending_permission;
303 let mut broker_resolution = None;
304 if let Some(pending) = pending_permission.as_mut() {
305 while let crate::permission::PermissionRequestState::Pending {
306 target: crate::permission::ApprovalTarget::Flow(target_run_id),
307 } = &pending.request.state
308 {
309 let target_run_id = target_run_id.clone();
310 tokio::select! {
311 changed = pending.target_changes.changed() => {
312 if changed.is_err() {
313 broker_resolution = Some((&mut pending.resolution).await);
317 break;
318 }
319 }
320 brokered = &mut pending.resolution => {
321 broker_resolution = Some(brokered);
322 break;
323 }
324 result = defer_after_ancestor_timeout(
325 ctx.permission_broker
326 .as_ref()
327 .expect("pending permission requires a broker"),
328 &pending.request.request_id,
329 &target_run_id,
330 ) => {
331 if let Err(error) = result {
332 return ApprovalOutcome::Deny {
333 reason: format!("{name}: permission timeout escalation failed: {error}"),
334 };
335 }
336 }
337 }
338 pending.request.state = crate::permission::PermissionRequestState::Pending {
339 target: pending.target_changes.borrow().clone(),
340 };
341 }
342 }
343 if let Some(result) = broker_resolution {
344 let (decision, execution_boundary) = match result {
345 Ok(crate::permission::PermissionResolution::Decision(decision))
346 if decision.action == crate::permission::PermissionAction::Approve =>
347 {
348 (
349 crate::session::ApprovalDecision::Approve,
350 decision.execution_boundary,
351 )
352 }
353 Ok(crate::permission::PermissionResolution::Decision(decision)) => (
354 crate::session::ApprovalDecision::Deny {
355 reason: decision
356 .reason
357 .unwrap_or_else(|| "denied by permission broker".into()),
358 },
359 crate::permission::ExecutionBoundary::Sandboxed,
360 ),
361 Ok(crate::permission::PermissionResolution::Cancelled { reason }) => (
362 crate::session::ApprovalDecision::Deny { reason },
363 crate::permission::ExecutionBoundary::Sandboxed,
364 ),
365 Err(_) => (
366 crate::session::ApprovalDecision::Deny {
367 reason: "permission request dropped".into(),
368 },
369 crate::permission::ExecutionBoundary::Sandboxed,
370 ),
371 };
372 return match decision {
373 crate::session::ApprovalDecision::Approve => ApprovalOutcome::Approve {
374 authorization: Box::new(permit(
375 pending_permission
376 .as_ref()
377 .expect("pending permission")
378 .request
379 .request_id
380 .clone(),
381 execution_boundary,
382 )),
383 },
384 crate::session::ApprovalDecision::Deny { reason } => ApprovalOutcome::Deny { reason },
385 };
386 }
387 let Some(pending) = pending_permission else {
388 return ApprovalOutcome::Deny {
389 reason: format!("{name}: blocked — permission request transport unavailable"),
390 };
391 };
392 let broker = ctx
393 .permission_broker
394 .as_ref()
395 .expect("pending permission requires a broker");
396 if !broker.has_clients() {
397 let reason = "no permission client".to_string();
398 if let Err(error) = broker.cancel(&pending.request.request_id, reason.clone())
399 && error != crate::permission::PermissionError::AlreadyResolved
400 {
401 crate::notify!(warn, "permission cancel failed: {error}");
402 }
403 return ApprovalOutcome::Deny { reason };
404 }
405 let request_id = pending.request.request_id.clone();
406 let (decision, execution_boundary) = match pending.resolution.await {
407 Ok(crate::permission::PermissionResolution::Decision(decision))
408 if decision.action == crate::permission::PermissionAction::Approve =>
409 {
410 (
411 crate::session::ApprovalDecision::Approve,
412 decision.execution_boundary,
413 )
414 }
415 Ok(crate::permission::PermissionResolution::Decision(decision)) => (
416 crate::session::ApprovalDecision::Deny {
417 reason: decision
418 .reason
419 .unwrap_or_else(|| "denied by permission broker".into()),
420 },
421 crate::permission::ExecutionBoundary::Sandboxed,
422 ),
423 Ok(crate::permission::PermissionResolution::Cancelled { reason }) => (
424 crate::session::ApprovalDecision::Deny { reason },
425 crate::permission::ExecutionBoundary::Sandboxed,
426 ),
427 Err(_) => (
428 crate::session::ApprovalDecision::Deny {
429 reason: "permission request dropped".into(),
430 },
431 crate::permission::ExecutionBoundary::Sandboxed,
432 ),
433 };
434 match decision {
435 crate::session::ApprovalDecision::Approve => ApprovalOutcome::Approve {
436 authorization: Box::new(permit(request_id, execution_boundary)),
437 },
438 crate::session::ApprovalDecision::Deny { reason } => ApprovalOutcome::Deny { reason },
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use crate::event::FlowRunId;
446 use crate::flow_authority::EffectiveAuthority;
447 use crate::permission::PermissionBroker;
448 use crate::session::ApprovalRegistry;
449 use crate::tool::{Tier, Tool};
450 use crate::tools::agent_ctrl::FlowRegistry;
451 use crate::trust::{
452 PolicyAction, TierPolicyConfig, TierPolicyOverrides, TrustConfig, TrustMode,
453 };
454 use std::sync::Arc;
455
456 struct Tier2Tool;
457
458 struct ProcessTool;
459
460 impl crate::tool::Tool for Tier2Tool {
461 fn name(&self) -> &str {
462 "probe.tool"
463 }
464
465 fn tier(&self) -> Tier {
466 Tier::Two
467 }
468
469 fn call<'a>(
470 &'a self,
471 _args: ToolArgs,
472 _ctx: &'a ToolCtx,
473 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
474 Box::pin(async move { Ok(crate::value::Value::Unit) })
475 }
476 }
477
478 impl crate::tool::Tool for ProcessTool {
479 fn name(&self) -> &str {
480 "process.tool"
481 }
482
483 fn tier(&self) -> Tier {
484 Tier::Two
485 }
486
487 fn invocation_provenance(
488 &self,
489 _args: &ToolArgs,
490 _ctx: &ToolCtx,
491 ) -> Result<crate::permission::ResourceProvenance, crate::error::RuntimeError> {
492 Ok(crate::permission::ResourceProvenance::none()
493 .with_risk(crate::trust::RiskKind::ProcessSpawn))
494 }
495
496 fn call<'a>(
497 &'a self,
498 _args: ToolArgs,
499 _ctx: &'a ToolCtx,
500 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
501 Box::pin(async move { Ok(crate::value::Value::Unit) })
502 }
503 }
504
505 fn ctx_with_broker(trust: TrustConfig) -> (ToolCtx, Arc<ApprovalRegistry>, Arc<FlowRegistry>) {
506 let flows = Arc::new(FlowRegistry::new());
507 let broker = PermissionBroker::shared(Arc::clone(&flows));
508 std::mem::forget(broker.register_client());
509 let run_id = FlowRunId::now();
510 let identity = flows
511 .register_root(
512 "sess".into(),
513 run_id.clone(),
514 EffectiveAuthority::root(&trust, true, None),
515 )
516 .unwrap();
517 let approval = Arc::new(ApprovalRegistry::new());
518 let mut ctx = ToolCtx::new();
519 ctx.approval = Some(Arc::clone(&approval));
520 ctx.permission_broker = Some(broker);
521 ctx.flow_registry = Some(Arc::clone(&flows));
522 ctx.flow_identity = Some(identity);
523 ctx.flow_run_id = Some(run_id);
524 ctx.trust = Some(trust);
525 (ctx, approval, flows)
526 }
527
528 fn ctx_without_permission_client(trust: TrustConfig) -> ToolCtx {
529 let flows = Arc::new(FlowRegistry::new());
530 let broker = PermissionBroker::shared(Arc::clone(&flows));
531 let run_id = FlowRunId::now();
532 let identity = flows
533 .register_root(
534 "sess".into(),
535 run_id.clone(),
536 EffectiveAuthority::root(&trust, true, None),
537 )
538 .unwrap();
539 let mut ctx = ToolCtx::new()
540 .with_permission_broker(broker)
541 .with_flow_registry(flows)
542 .with_trust(trust)
543 .with_anchors(None, Some(run_id), None);
544 ctx.flow_identity = Some(identity);
545 ctx
546 }
547
548 fn resolve_as_user(
549 broker: &Arc<PermissionBroker>,
550 request_id: crate::permission::PermissionRequestId,
551 action: crate::permission::PermissionAction,
552 reason: Option<String>,
553 ) {
554 let expected = std::collections::HashMap::from([(
555 request_id.clone(),
556 broker.get(&request_id).unwrap().revision,
557 )]);
558 broker
559 .user_resolve(
560 "sess",
561 Some("test-user".into()),
562 vec![request_id],
563 &expected,
564 None,
565 action,
566 (action == crate::permission::PermissionAction::Approve)
567 .then_some(crate::permission::GrantScope::CurrentCall),
568 reason,
569 )
570 .unwrap();
571 }
572
573 fn deny_tier2() -> TrustConfig {
574 TrustConfig {
575 mode: TrustMode::Eager,
576 tiers: TierPolicyConfig {
577 eager: TierPolicyOverrides {
578 tier2: Some(PolicyAction::Deny),
579 ..TierPolicyOverrides::default()
580 },
581 },
582 ..TrustConfig::default()
583 }
584 }
585
586 #[test]
587 fn temp_targets_do_not_add_workspace_external_risk() {
588 let workspace = tempfile::tempdir().unwrap();
589 let scratch = tempfile::tempdir().unwrap();
590 let ctx = ToolCtx::new().with_workspace(crate::git_workspace::WorkspaceBinding {
591 workspace_id: "test".into(),
592 repository_root: workspace.path().to_path_buf(),
593 path: workspace.path().to_path_buf(),
594 branch: None,
595 });
596 let provenance = crate::permission::ResourceProvenance::for_ctx(&ctx)
597 .with_cwd(&ctx, Some(scratch.path()))
598 .unwrap();
599
600 let risks = intent_risks(Tier::Four, &provenance);
601
602 assert!(risks.contains(&crate::trust::RiskKind::ProcessSpawn));
603 assert!(!risks.contains(&crate::trust::RiskKind::WorkspaceExternal));
604 }
605
606 #[test]
607 fn non_temp_external_targets_keep_workspace_external_risk() {
608 let workspace = tempfile::tempdir().unwrap();
609 let ctx = ToolCtx::new().with_workspace(crate::git_workspace::WorkspaceBinding {
610 workspace_id: "test".into(),
611 repository_root: workspace.path().to_path_buf(),
612 path: workspace.path().to_path_buf(),
613 branch: None,
614 });
615 let provenance = crate::permission::ResourceProvenance::for_ctx(&ctx)
616 .with_cwd(&ctx, Some(std::path::Path::new("/etc")))
617 .unwrap();
618
619 let risks = intent_risks(Tier::Four, &provenance);
620
621 assert!(risks.contains(&crate::trust::RiskKind::WorkspaceExternal));
622 }
623
624 #[tokio::test(start_paused = true)]
625 async fn ancestor_offer_waits_exactly_thirty_seconds_before_deferring() {
626 let flows = Arc::new(FlowRegistry::new());
627 let trust = TrustConfig {
628 mode: TrustMode::Steady,
629 ..TrustConfig::default()
630 };
631 let root = flows
632 .register_root(
633 "sess".into(),
634 FlowRunId::now(),
635 EffectiveAuthority::root(&trust, true, None),
636 )
637 .unwrap();
638 let requester = flows
639 .register_child(
640 &root.run_id,
641 FlowRunId::now(),
642 crate::flow_authority::InvocationKind::InlineSubflow,
643 true,
644 crate::flow_authority::ChildWorkspaceAuthority::Inherit,
645 )
646 .unwrap();
647 let broker = PermissionBroker::shared(Arc::clone(&flows));
648 let pending = match broker
649 .submit(
650 Some(&requester.session_id),
651 Some(&requester.run_id),
652 crate::permission::PermissionIntent {
653 tool_use_id: "call-1".into(),
654 tool_name: "probe.tool".into(),
655 call_intent: None,
656 tier: Tier::Two,
657 risks: Default::default(),
658 args_digest: "sha256:test".into(),
659 preview: None,
660 provenance: crate::permission::ResourceProvenance::none(),
661 },
662 false,
663 &trust,
664 )
665 .unwrap()
666 {
667 crate::permission::SubmissionOutcome::Pending(pending) => pending,
668 _ => panic!("expected pending"),
669 };
670 let request_id = pending.request.request_id.clone();
671 let root_run_id = root.run_id.clone();
672 let waiter = tokio::spawn({
673 let broker = Arc::clone(&broker);
674 async move { defer_after_ancestor_timeout(&broker, &request_id, &root_run_id).await }
675 });
676
677 tokio::time::advance(std::time::Duration::from_secs(29)).await;
678 tokio::task::yield_now().await;
679 assert!(!waiter.is_finished());
680 assert!(matches!(
681 broker.get(&pending.request.request_id).unwrap().state,
682 crate::permission::PermissionRequestState::Pending {
683 target: crate::permission::ApprovalTarget::Flow(_)
684 }
685 ));
686 tokio::time::advance(std::time::Duration::from_secs(1)).await;
687 assert!(waiter.await.unwrap().unwrap());
688 assert_eq!(
689 broker.get(&pending.request.request_id).unwrap().state,
690 crate::permission::PermissionRequestState::Pending {
691 target: crate::permission::ApprovalTarget::User
692 }
693 );
694 }
695
696 #[tokio::test]
697 async fn ancestor_can_approve_descendant_before_user_prompt() {
698 let trust = TrustConfig {
699 mode: TrustMode::Steady,
700 ..TrustConfig::default()
701 };
702 let flows = Arc::new(FlowRegistry::new());
703 let root = flows
704 .register_root(
705 "sess".into(),
706 FlowRunId::now(),
707 EffectiveAuthority::root(&trust, true, None),
708 )
709 .unwrap();
710 let child = flows
711 .register_child(
712 &root.run_id,
713 FlowRunId::now(),
714 crate::flow_authority::InvocationKind::InlineSubflow,
715 true,
716 crate::flow_authority::ChildWorkspaceAuthority::Inherit,
717 )
718 .unwrap();
719 let broker = PermissionBroker::shared(Arc::clone(&flows));
720 let approval = Arc::new(ApprovalRegistry::new());
721 let _approval_watch = approval.subscribe();
722 let mut ctx = ToolCtx::new()
723 .with_approval(Arc::clone(&approval))
724 .with_permission_broker(Arc::clone(&broker))
725 .with_flow_registry(Arc::clone(&flows))
726 .with_trust(trust)
727 .with_anchors(None, Some(child.run_id.clone()), None);
728 ctx.flow_identity = Some(child);
729 let request = tokio::spawn(async move {
730 request_approval(
731 &ctx,
732 "ancestor-approve",
733 "probe.tool",
734 &ToolArgs::default(),
735 ApprovalLevel::Approve,
736 Some(&Tier2Tool),
737 )
738 .await
739 });
740 let request_id = loop {
741 if let Some(permission) = broker.list().into_iter().next() {
742 break permission.request_id;
743 }
744 tokio::task::yield_now().await;
745 };
746 assert!(approval.list_pending().is_empty());
747 let authority =
748 crate::permission::DecisionAuthority::Flow(broker.flow_authority(root).unwrap());
749 broker
750 .resolve(
751 &request_id,
752 &authority,
753 crate::permission::PermissionAction::Approve,
754 Some(crate::permission::GrantScope::CurrentCall),
755 None,
756 )
757 .unwrap();
758 assert!(matches!(
759 request.await.unwrap(),
760 ApprovalOutcome::Approve { .. }
761 ));
762 assert!(approval.list_pending().is_empty());
763 }
764
765 #[tokio::test(start_paused = true)]
766 async fn timed_out_ancestor_offer_reaches_user_prompt() {
767 let trust = TrustConfig {
768 mode: TrustMode::Steady,
769 ..TrustConfig::default()
770 };
771 let flows = Arc::new(FlowRegistry::new());
772 let root = flows
773 .register_root(
774 "sess".into(),
775 FlowRunId::now(),
776 EffectiveAuthority::root(&trust, true, None),
777 )
778 .unwrap();
779 let child = flows
780 .register_child(
781 &root.run_id,
782 FlowRunId::now(),
783 crate::flow_authority::InvocationKind::InlineSubflow,
784 true,
785 crate::flow_authority::ChildWorkspaceAuthority::Inherit,
786 )
787 .unwrap();
788 let broker = PermissionBroker::shared(Arc::clone(&flows));
789 let approval = Arc::new(ApprovalRegistry::new());
790 let _client = broker.register_client();
791 let mut ctx = ToolCtx::new()
792 .with_approval(Arc::clone(&approval))
793 .with_permission_broker(Arc::clone(&broker))
794 .with_flow_registry(Arc::clone(&flows))
795 .with_trust(trust)
796 .with_anchors(None, Some(child.run_id.clone()), None);
797 ctx.flow_identity = Some(child);
798 let request = tokio::spawn(async move {
799 request_approval(
800 &ctx,
801 "ancestor-timeout",
802 "probe.tool",
803 &ToolArgs::default(),
804 ApprovalLevel::Approve,
805 Some(&Tier2Tool),
806 )
807 .await
808 });
809 let request_id = loop {
810 if let Some(permission) = broker.list().into_iter().next() {
811 break permission.request_id;
812 }
813 tokio::task::yield_now().await;
814 };
815 tokio::time::advance(crate::permission::ANCESTOR_OFFER_TIMEOUT).await;
816 tokio::task::yield_now().await;
817 assert!(matches!(
818 broker.get(&request_id).unwrap().state,
819 crate::permission::PermissionRequestState::Pending {
820 target: crate::permission::ApprovalTarget::User
821 }
822 ));
823 let expected = std::collections::HashMap::from([(
824 request_id.clone(),
825 broker.get(&request_id).unwrap().revision,
826 )]);
827 broker
828 .user_resolve(
829 "sess",
830 Some("test-user".into()),
831 vec![request_id],
832 &expected,
833 None,
834 crate::permission::PermissionAction::Approve,
835 Some(crate::permission::GrantScope::CurrentCall),
836 None,
837 )
838 .unwrap();
839 assert!(approval.list_pending().is_empty());
840 assert!(matches!(
841 request.await.unwrap(),
842 ApprovalOutcome::Approve { .. }
843 ));
844 }
845
846 async fn assert_gate_denied(ctx: &ToolCtx, expected: &str) {
847 let outcome = request_approval(
848 ctx,
849 "tu_fail_closed",
850 "probe.tool",
851 &ToolArgs::default(),
852 ApprovalLevel::Approve,
853 Some(&Tier2Tool),
854 )
855 .await;
856 let ApprovalOutcome::Deny { reason } = outcome else {
857 panic!("expected fail-closed denial");
858 };
859 assert!(reason.contains(expected), "unexpected denial: {reason}");
860 }
861
862 #[tokio::test]
863 async fn no_broker_fails_closed_instead_of_legacy_auto_approve() {
864 let (mut ctx, approval, _flows) = ctx_with_broker(TrustConfig::default());
865 ctx.permission_broker = None;
866 assert_gate_denied(&ctx, "missing permission broker").await;
867 assert!(approval.list_pending().is_empty());
868 }
869
870 #[tokio::test]
871 async fn missing_flow_identity_fails_closed() {
872 let (mut ctx, approval, _flows) = ctx_with_broker(TrustConfig::default());
873 ctx.flow_identity = None;
874 assert_gate_denied(&ctx, "missing flow identity").await;
875 assert!(approval.list_pending().is_empty());
876 }
877
878 #[tokio::test]
879 async fn missing_trust_snapshot_fails_closed() {
880 let (mut ctx, approval, _flows) = ctx_with_broker(TrustConfig::default());
881 ctx.trust = None;
882 assert_gate_denied(&ctx, "missing trust snapshot").await;
883 assert!(approval.list_pending().is_empty());
884 }
885
886 #[tokio::test]
887 async fn missing_flow_registry_fails_closed() {
888 let (mut ctx, approval, _flows) = ctx_with_broker(TrustConfig::default());
889 ctx.flow_registry = None;
890 assert_gate_denied(&ctx, "missing flow registry").await;
891 assert!(approval.list_pending().is_empty());
892 }
893
894 #[tokio::test]
895 async fn broker_registry_mismatch_fails_closed() {
896 let (mut ctx, approval, _flows) = ctx_with_broker(TrustConfig::default());
897 ctx.flow_registry = Some(Arc::new(FlowRegistry::new()));
898 assert_gate_denied(&ctx, "broker and flow registry mismatch").await;
899 assert!(approval.list_pending().is_empty());
900 }
901
902 #[tokio::test]
905 async fn broker_policy_denies_without_prompting() {
906 let (ctx, approval, _flows) = ctx_with_broker(deny_tier2());
907 let outcome = request_approval(
908 &ctx,
909 "tu1",
910 "probe.tool",
911 &ToolArgs::default(),
912 ApprovalLevel::Approve,
913 Some(&Tier2Tool),
914 )
915 .await;
916 assert!(matches!(outcome, ApprovalOutcome::Deny { .. }));
917 assert!(approval.list_pending().is_empty());
918 }
919
920 #[tokio::test]
921 async fn broker_auto_approves_without_prompt_transport() {
922 let trust = TrustConfig {
923 mode: TrustMode::Eager,
924 tiers: TierPolicyConfig {
925 eager: TierPolicyOverrides {
926 tier2: Some(PolicyAction::Auto),
927 ..TierPolicyOverrides::default()
928 },
929 },
930 ..TrustConfig::default()
931 };
932 let (mut ctx, approval, _flows) = ctx_with_broker(trust);
933 ctx.approval = None;
934 let outcome = request_approval(
935 &ctx,
936 "tu_auto_headless",
937 "probe.tool",
938 &ToolArgs::default(),
939 ApprovalLevel::Approve,
940 Some(&Tier2Tool),
941 )
942 .await;
943 assert!(matches!(outcome, ApprovalOutcome::Approve { .. }));
944 assert!(approval.list_pending().is_empty());
945 }
946
947 #[tokio::test]
948 async fn broker_pending_without_approval_consumer_fails_closed() {
949 let trust = TrustConfig {
950 mode: TrustMode::Steady,
951 ..TrustConfig::default()
952 };
953 let ctx = ctx_without_permission_client(trust);
954 let broker = ctx.permission_broker.clone().unwrap();
955 let gate = tokio::spawn({
956 let ctx = ctx.clone();
957 async move {
958 request_approval(
959 &ctx,
960 "tu_headless",
961 "probe.tool",
962 &ToolArgs::default(),
963 ApprovalLevel::Auto,
964 Some(&Tier2Tool),
965 )
966 .await
967 }
968 });
969 let request_id = loop {
970 if let Some(req) = broker.list().first() {
971 break req.request_id.clone();
972 }
973 tokio::task::yield_now().await;
974 };
975 let outcome = gate.await.unwrap();
976 assert!(matches!(outcome, ApprovalOutcome::Deny { .. }));
977 assert!(matches!(
978 broker.get(&request_id).map(|request| request.state),
979 Some(crate::permission::PermissionRequestState::Cancelled { .. })
980 ));
981 }
982
983 #[tokio::test]
984 async fn last_permission_client_disconnect_cancels_waiting_invocation() {
985 let trust = TrustConfig {
986 mode: TrustMode::Steady,
987 ..TrustConfig::default()
988 };
989 let ctx = ctx_without_permission_client(trust);
990 let broker = ctx.permission_broker.clone().unwrap();
991 let first = broker.register_client();
992 let second = broker.register_client();
993 let gate = tokio::spawn({
994 let ctx = ctx.clone();
995 async move {
996 request_approval(
997 &ctx,
998 "client-disconnect",
999 "probe.tool",
1000 &ToolArgs::default(),
1001 ApprovalLevel::Approve,
1002 Some(&Tier2Tool),
1003 )
1004 .await
1005 }
1006 });
1007 let request_id = loop {
1008 if let Some(request) = broker.list().first() {
1009 break request.request_id.clone();
1010 }
1011 tokio::task::yield_now().await;
1012 };
1013 drop(first);
1014 assert!(matches!(
1015 broker.get(&request_id).map(|request| request.state),
1016 Some(crate::permission::PermissionRequestState::Pending { .. })
1017 ));
1018 drop(second);
1019 assert!(matches!(gate.await.unwrap(), ApprovalOutcome::Deny { .. }));
1020 assert!(matches!(
1021 broker.get(&request_id).map(|request| request.state),
1022 Some(crate::permission::PermissionRequestState::Cancelled { .. })
1023 ));
1024 }
1025
1026 #[tokio::test]
1027 async fn queued_approve_after_terminal_cancellation_fails_closed() {
1028 let trust = TrustConfig {
1029 mode: TrustMode::Steady,
1030 ..TrustConfig::default()
1031 };
1032 let (ctx, _approval, flows) = ctx_with_broker(trust);
1033 let broker = ctx.permission_broker.clone().unwrap();
1034 let identity = ctx.flow_identity.as_ref().unwrap();
1035 let outcome = broker
1036 .submit(
1037 Some(&identity.session_id),
1038 Some(&identity.run_id),
1039 crate::permission::PermissionIntent::minimal("race", "probe.tool", Tier::Two),
1040 false,
1041 ctx.trust.as_ref().unwrap(),
1042 )
1043 .unwrap();
1044 let crate::permission::SubmissionOutcome::Pending(pending) = outcome else {
1045 panic!("expected pending request");
1046 };
1047 let request_id = pending.request.request_id.clone();
1048 flows.mark_terminal(&identity.run_id);
1049
1050 let decision =
1051 settle_broker_request(&ctx, &request_id, crate::session::ApprovalDecision::Approve);
1052
1053 assert!(matches!(
1054 decision,
1055 crate::session::ApprovalDecision::Deny { .. }
1056 ));
1057 assert!(matches!(
1058 broker.get(&request_id).map(|request| request.state),
1059 Some(crate::permission::PermissionRequestState::Cancelled { .. })
1060 ));
1061 }
1062
1063 #[tokio::test]
1064 async fn queued_denial_preserves_reason_and_settles_without_a_grant_scope() {
1065 let trust = TrustConfig {
1066 mode: TrustMode::Steady,
1067 ..TrustConfig::default()
1068 };
1069 let (ctx, approval, _flows) = ctx_with_broker(trust);
1070 let broker = ctx.permission_broker.clone().unwrap();
1071 let gate = tokio::spawn({
1072 let ctx = ctx.clone();
1073 async move {
1074 request_approval(
1075 &ctx,
1076 "tu_deny",
1077 "probe.tool",
1078 &ToolArgs::default(),
1079 ApprovalLevel::Auto,
1080 Some(&Tier2Tool),
1081 )
1082 .await
1083 }
1084 });
1085 let request_id = loop {
1086 if let Some(req) = broker.list().first() {
1087 break req.request_id.clone();
1088 }
1089 tokio::task::yield_now().await;
1090 };
1091 resolve_as_user(
1092 &broker,
1093 request_id.clone(),
1094 crate::permission::PermissionAction::Deny,
1095 Some("operator denied".into()),
1096 );
1097 assert!(approval.list_pending().is_empty());
1098
1099 let outcome = gate.await.unwrap();
1100 assert!(matches!(
1101 outcome,
1102 ApprovalOutcome::Deny { ref reason } if reason == "operator denied"
1103 ));
1104 assert!(matches!(
1105 broker.get(&request_id).map(|request| request.state),
1106 Some(crate::permission::PermissionRequestState::Denied { .. })
1107 ));
1108 }
1109
1110 #[tokio::test]
1111 async fn broker_user_decision_settles_waiting_invocation() {
1112 let trust = TrustConfig {
1113 mode: TrustMode::Steady,
1114 ..TrustConfig::default()
1115 };
1116 let (ctx, approval, _flows) = ctx_with_broker(trust);
1117 let broker = ctx.permission_broker.clone().unwrap();
1118 let gate = tokio::spawn({
1119 let ctx = ctx.clone();
1120 async move {
1121 request_approval(
1122 &ctx,
1123 "tu2",
1124 "probe.tool",
1125 &ToolArgs::default(),
1126 ApprovalLevel::Auto,
1127 Some(&Tier2Tool),
1128 )
1129 .await
1130 }
1131 });
1132 let request_id = loop {
1133 if let Some(req) = broker.list().first() {
1134 break req.request_id.clone();
1135 }
1136 tokio::task::yield_now().await;
1137 };
1138 assert!(approval.list_pending().is_empty());
1139 resolve_as_user(
1140 &broker,
1141 request_id.clone(),
1142 crate::permission::PermissionAction::Approve,
1143 None,
1144 );
1145 let outcome = gate.await.unwrap();
1146 let ApprovalOutcome::Approve { authorization } = outcome else {
1147 panic!("expected approval");
1148 };
1149 assert_eq!(authorization.request_id(), &request_id);
1150 assert!(matches!(
1151 broker.get(&request_id).map(|r| r.state),
1152 Some(crate::permission::PermissionRequestState::Approved { .. })
1153 ));
1154 }
1155
1156 #[tokio::test]
1157 async fn process_authorization_carries_selected_execution_boundary() {
1158 let (ctx, _approval, _flows) = ctx_with_broker(TrustConfig {
1159 mode: TrustMode::Steady,
1160 ..TrustConfig::default()
1161 });
1162 let broker = ctx.permission_broker.clone().unwrap();
1163 let gate = tokio::spawn({
1164 let ctx = ctx.clone();
1165 async move {
1166 request_approval(
1167 &ctx,
1168 "process-user",
1169 "process.tool",
1170 &ToolArgs::default(),
1171 ApprovalLevel::Approve,
1172 Some(&ProcessTool),
1173 )
1174 .await
1175 }
1176 });
1177 let request_id = loop {
1178 if let Some(request) = broker.list().first() {
1179 break request.request_id.clone();
1180 }
1181 tokio::task::yield_now().await;
1182 };
1183 resolve_as_user(
1184 &broker,
1185 request_id,
1186 crate::permission::PermissionAction::Approve,
1187 None,
1188 );
1189 let ApprovalOutcome::Approve { authorization } = gate.await.unwrap() else {
1190 panic!("expected user approval");
1191 };
1192 assert_eq!(
1193 authorization.execution_boundary(),
1194 crate::permission::ExecutionBoundary::Direct
1195 );
1196
1197 let (ctx, _approval, _flows) = ctx_with_broker(TrustConfig {
1198 mode: TrustMode::Eager,
1199 escalation: crate::trust::EscalationPolicy::Allow,
1200 ..TrustConfig::default()
1201 });
1202 let ApprovalOutcome::Approve { authorization } = request_approval(
1203 &ctx,
1204 "process-eager-allow",
1205 "process.tool",
1206 &ToolArgs::default(),
1207 ApprovalLevel::Approve,
1208 Some(&ProcessTool),
1209 )
1210 .await
1211 else {
1212 panic!("expected eager allow authorization");
1213 };
1214 assert_eq!(
1215 authorization.execution_boundary(),
1216 crate::permission::ExecutionBoundary::Direct
1217 );
1218 }
1219
1220 #[tokio::test]
1221 async fn approved_write_consumes_one_gate_without_second_prompt() {
1222 let trust = TrustConfig {
1223 mode: TrustMode::Steady,
1224 ..TrustConfig::default()
1225 };
1226 let (mut ctx, approval, _flows) = ctx_with_broker(trust);
1227 ctx.fs_access = crate::fs_access::FsAccessPolicy {
1228 mode: crate::fs_access::FsAccessMode::ReadOnly,
1229 workspace: None,
1230 };
1231 let dir = tempfile::tempdir().unwrap();
1232 let target = dir.path().join("approved.txt");
1233 let args = ToolArgs {
1234 named: vec![
1235 ("path".into(), crate::value::Value::Path(target.clone())),
1236 ("content".into(), crate::value::Value::Str("ok".into())),
1237 ],
1238 ..ToolArgs::default()
1239 };
1240 let gate = tokio::spawn({
1241 let ctx = ctx.clone();
1242 let args = args.clone();
1243 async move {
1244 request_approval(
1245 &ctx,
1246 "write-1",
1247 "fs.write",
1248 &args,
1249 ApprovalLevel::Approve,
1250 Some(&crate::tools::fs::FsWrite),
1251 )
1252 .await
1253 }
1254 });
1255 let broker = ctx.permission_broker.clone().unwrap();
1256 let request_id = loop {
1257 if let Some(request) = broker.list().first() {
1258 break request.request_id.clone();
1259 }
1260 tokio::task::yield_now().await;
1261 };
1262 assert!(approval.list_pending().is_empty());
1263 resolve_as_user(
1264 &broker,
1265 request_id,
1266 crate::permission::PermissionAction::Approve,
1267 None,
1268 );
1269 let ApprovalOutcome::Approve { authorization } = gate.await.unwrap() else {
1270 panic!("expected approval");
1271 };
1272 let call_ctx = ctx.authorized_for(*authorization);
1273 crate::tools::fs::FsWrite
1274 .call(args, &call_ctx)
1275 .await
1276 .unwrap();
1277
1278 assert_eq!(tokio::fs::read_to_string(target).await.unwrap(), "ok");
1279 assert!(approval.list_pending().is_empty());
1280 }
1281
1282 #[tokio::test]
1285 async fn broker_cancellation_clears_queued_prompt() {
1286 let trust = TrustConfig {
1287 mode: TrustMode::Steady,
1288 ..TrustConfig::default()
1289 };
1290 let (ctx, approval, flows) = ctx_with_broker(trust);
1291 let broker = ctx.permission_broker.clone().unwrap();
1292 let run_id = ctx.flow_run_id.clone().unwrap();
1293 let gate = tokio::spawn({
1294 let ctx = ctx.clone();
1295 async move {
1296 request_approval(
1297 &ctx,
1298 "tu3",
1299 "probe.tool",
1300 &ToolArgs::default(),
1301 ApprovalLevel::Approve,
1302 Some(&Tier2Tool),
1303 )
1304 .await
1305 }
1306 });
1307 while broker.list().is_empty() {
1308 tokio::task::yield_now().await;
1309 }
1310 flows.mark_terminal(&run_id);
1311 let outcome = gate.await.unwrap();
1312 assert!(matches!(outcome, ApprovalOutcome::Deny { .. }));
1313 assert!(approval.list_pending().is_empty());
1314 }
1315}