Skip to main content

atman_runtime/
approval.rs

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
35// Risks only tighten policy, so read-only tools must not inherit mutation risks.
36fn 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    // A broker bound to a different registry would authenticate this identity
79    // against a foreign authority graph, so refuse rather than mis-authorize.
80    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        // The resolver refused to classify the target (relative path escaping a
212        // managed workspace). Authorizing an unclassified resource would defeat
213        // the gate, so fail closed.
214        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                        // Terminal archival closes the target watch after settling the
314                        // authoritative one-shot, so consume that resolution instead of
315                        // racing the two channels into a false denial.
316                        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    /// The broker's policy verdict must reach the caller. Without wiring, the legacy
903    /// path would queue a prompt instead of denying outright.
904    #[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    /// When the requesting run goes terminal, the broker cancels and the queued
1283    /// prompt must disappear rather than waiting on a user who has nothing to answer.
1284    #[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}