Skip to main content

codewhale_workflow/
elevation.rs

1//! Elevated Workflow plan assessment for approval cards (#4126).
2//!
3//! Pure, UI-free analysis of a [`WorkflowSpec`] (and optional planner risk
4//! string) so callers can decide whether an operator approval card is required
5//! and what fields that card should show.
6
7use serde::{Deserialize, Serialize};
8
9use crate::{
10    IsolationMode, LeafSpec, PermissionSpec, TaskMode, WorkflowNode, WorkflowSpec,
11    leaf_is_write_capable, leaf_wants_worktree,
12};
13
14/// Default soft token budget from product config (`[workflow].default_token_budget`).
15/// Plans requesting more than this are treated as high-budget.
16pub const DEFAULT_HIGH_BUDGET_THRESHOLD: u64 = 120_000;
17
18/// Options that refine elevation assessment beyond the IR itself.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct ElevationOptions {
21    /// Token budget declared on the tool call (may outrank `spec.budget`).
22    pub token_budget: Option<u64>,
23    /// Threshold above which a token budget is considered high.
24    pub high_budget_threshold: u64,
25    /// Whether the parent session currently allows writes.
26    pub parent_allows_write: bool,
27    /// Whether the parent session currently allows network.
28    pub parent_allows_network: bool,
29}
30
31impl Default for ElevationOptions {
32    fn default() -> Self {
33        Self {
34            token_budget: None,
35            high_budget_threshold: DEFAULT_HIGH_BUDGET_THRESHOLD,
36            // Assume Act/read-write parent unless callers narrow posture.
37            parent_allows_write: true,
38            parent_allows_network: true,
39        }
40    }
41}
42
43/// Summary of why a Workflow plan needs (or does not need) elevated approval.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct WorkflowPlanElevation {
46    pub elevated: bool,
47    pub goal: String,
48    pub child_count: usize,
49    pub child_summary: String,
50    pub writes: bool,
51    pub shell: bool,
52    pub network: bool,
53    pub secrets: bool,
54    pub worktree: bool,
55    pub high_budget: bool,
56    pub broader_authority: bool,
57    /// Human-readable budget line for the approval card.
58    pub budget_label: String,
59    /// Distinct elevation reasons (for audit / impact lines).
60    pub reasons: Vec<String>,
61}
62
63impl WorkflowPlanElevation {
64    /// Card field labels/values used by the TUI approval modal (#4126).
65    #[must_use]
66    pub fn card_fields(&self) -> Vec<(&'static str, String)> {
67        vec![
68            ("Goal", self.goal.clone()),
69            ("Children", self.child_summary.clone()),
70            ("Writes", yes_no(self.writes)),
71            ("Shell", yes_no(self.shell)),
72            ("Network", yes_no(self.network)),
73            ("Budget", self.budget_label.clone()),
74        ]
75    }
76
77    /// True when the plan is fully inside the read-only envelope.
78    #[must_use]
79    pub fn is_read_only_envelope(&self) -> bool {
80        !self.elevated
81            && !self.writes
82            && !self.shell
83            && !self.network
84            && !self.secrets
85            && !self.worktree
86            && !self.high_budget
87            && !self.broader_authority
88    }
89}
90
91fn yes_no(flag: bool) -> String {
92    if flag {
93        "yes".to_string()
94    } else {
95        "no".to_string()
96    }
97}
98
99/// Assess elevation for a compiled [`WorkflowSpec`].
100#[must_use]
101pub fn assess_workflow_elevation(
102    spec: &WorkflowSpec,
103    options: ElevationOptions,
104) -> WorkflowPlanElevation {
105    let mut child_ids = Vec::new();
106    let mut writes = false;
107    let mut shell = false;
108    let mut network = false;
109    let mut secrets = false;
110    let mut worktree = false;
111
112    walk_nodes(
113        &spec.nodes,
114        /* parallel */ false,
115        &mut child_ids,
116        &mut writes,
117        &mut shell,
118        &mut network,
119        &mut secrets,
120        &mut worktree,
121    );
122
123    // Spec-level permissions also elevate.
124    merge_permissions(
125        &spec.permissions,
126        &mut writes,
127        &mut shell,
128        &mut network,
129        &mut secrets,
130    );
131
132    // The structured-plan lowerer stores its validated risk enum on
133    // `description`, while authored Workflow specs use that field for ordinary
134    // prose. Only consume recognized enum values here: treating free-form
135    // descriptions as unknown risk would falsely report writes, shell, and
136    // network in the approval receipt. Unknown planner risk remains fail-closed
137    // in `assess_plan_risk_string` and is rejected before structured lowering.
138    if let Some(risk) = embedded_plan_risk_hint(spec.description.as_deref()) {
139        apply_plan_risk_hint(Some(risk), &mut writes, &mut shell, &mut network);
140    }
141
142    let effective_tokens = options
143        .token_budget
144        .or(spec.budget.max_tokens)
145        .filter(|n| *n > 0);
146    let high_budget = effective_tokens.is_some_and(|n| n > options.high_budget_threshold);
147
148    let broader_authority =
149        (!options.parent_allows_write && writes) || (!options.parent_allows_network && network);
150
151    let mut reasons = Vec::new();
152    if writes {
153        reasons.push("writes".to_string());
154    }
155    if shell {
156        reasons.push("shell".to_string());
157    }
158    if network {
159        reasons.push("network".to_string());
160    }
161    if secrets {
162        reasons.push("secrets".to_string());
163    }
164    if worktree {
165        reasons.push("worktree".to_string());
166    }
167    if high_budget {
168        reasons.push("high_budget".to_string());
169    }
170    if broader_authority {
171        reasons.push("broader_authority".to_string());
172    }
173
174    let elevated = !reasons.is_empty();
175    let child_count = child_ids.len();
176    let child_summary = if child_ids.is_empty() {
177        "0 children".to_string()
178    } else if child_ids.len() <= 4 {
179        format!(
180            "{} child{}: {}",
181            child_ids.len(),
182            if child_ids.len() == 1 { "" } else { "ren" },
183            child_ids.join(", ")
184        )
185    } else {
186        format!(
187            "{} children: {}, {}… (+{})",
188            child_ids.len(),
189            child_ids[0],
190            child_ids[1],
191            child_ids.len() - 2
192        )
193    };
194
195    let budget_label = format_budget_label(effective_tokens, &spec.budget, high_budget);
196
197    WorkflowPlanElevation {
198        elevated,
199        goal: spec.goal.clone(),
200        child_count,
201        child_summary,
202        writes,
203        shell,
204        network,
205        secrets,
206        worktree,
207        high_budget,
208        broader_authority,
209        budget_label,
210        reasons,
211    }
212}
213
214/// Lightweight assessment from a planner `risk` string alone (before IR lower).
215#[must_use]
216pub fn assess_plan_risk_string(risk: Option<&str>) -> PlanRiskHint {
217    match risk.map(str::trim).filter(|s| !s.is_empty()) {
218        None | Some("read_only") | Some("readonly") | Some("low") | Some("safe") => {
219            PlanRiskHint::ReadOnly
220        }
221        Some("writes") | Some("write") | Some("read_write") | Some("readwrite")
222        | Some("medium") => PlanRiskHint::Writes,
223        Some("shell") => PlanRiskHint::Shell,
224        Some("network") => PlanRiskHint::Network,
225        Some("elevated") | Some("high") => PlanRiskHint::Elevated,
226        Some(_) => PlanRiskHint::Elevated,
227    }
228}
229
230/// Coarse risk classification from the structured plan `risk` field.
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum PlanRiskHint {
233    ReadOnly,
234    Writes,
235    Shell,
236    Network,
237    Elevated,
238}
239
240impl PlanRiskHint {
241    #[must_use]
242    pub fn elevates(self) -> bool {
243        !matches!(self, Self::ReadOnly)
244    }
245}
246
247fn apply_plan_risk_hint(
248    risk: Option<&str>,
249    writes: &mut bool,
250    shell: &mut bool,
251    network: &mut bool,
252) {
253    match assess_plan_risk_string(risk) {
254        PlanRiskHint::ReadOnly => {}
255        PlanRiskHint::Writes => *writes = true,
256        PlanRiskHint::Shell => {
257            *shell = true;
258            *writes = true;
259        }
260        PlanRiskHint::Network => {
261            *network = true;
262        }
263        PlanRiskHint::Elevated => {
264            *writes = true;
265            *shell = true;
266            *network = true;
267        }
268    }
269}
270
271fn embedded_plan_risk_hint(description: Option<&str>) -> Option<&str> {
272    let value = description
273        .map(str::trim)
274        .filter(|value| !value.is_empty())?;
275    matches!(
276        value,
277        "read_only"
278            | "readonly"
279            | "low"
280            | "safe"
281            | "writes"
282            | "write"
283            | "read_write"
284            | "readwrite"
285            | "medium"
286            | "shell"
287            | "network"
288            | "elevated"
289            | "high"
290    )
291    .then_some(value)
292}
293
294fn format_budget_label(
295    effective_tokens: Option<u64>,
296    budget: &crate::BudgetSpec,
297    high_budget: bool,
298) -> String {
299    let mut parts = Vec::new();
300    if let Some(tokens) = effective_tokens {
301        parts.push(format!("{tokens} tokens"));
302    }
303    if let Some(steps) = budget.max_steps {
304        parts.push(format!("max_steps={steps}"));
305    }
306    if let Some(timeout) = budget.timeout_secs {
307        parts.push(format!("timeout={timeout}s"));
308    }
309    if let Some(parallel) = budget.max_parallel {
310        parts.push(format!("max_parallel={parallel}"));
311    }
312    if parts.is_empty() {
313        "default".to_string()
314    } else if high_budget {
315        format!("{} (high)", parts.join(", "))
316    } else {
317        parts.join(", ")
318    }
319}
320
321#[allow(clippy::too_many_arguments)]
322fn walk_nodes(
323    nodes: &[WorkflowNode],
324    parallel: bool,
325    child_ids: &mut Vec<String>,
326    writes: &mut bool,
327    shell: &mut bool,
328    network: &mut bool,
329    secrets: &mut bool,
330    worktree: &mut bool,
331) {
332    for node in nodes {
333        match node {
334            WorkflowNode::Leaf(leaf) => {
335                inspect_leaf(
336                    leaf, parallel, child_ids, writes, shell, network, secrets, worktree,
337                );
338            }
339            WorkflowNode::BranchSet(branch) => {
340                merge_permissions(&branch.permissions, writes, shell, network, secrets);
341                walk_nodes(
342                    &branch.children,
343                    branch.parallel || parallel,
344                    child_ids,
345                    writes,
346                    shell,
347                    network,
348                    secrets,
349                    worktree,
350                );
351            }
352            WorkflowNode::Sequence(seq) => {
353                walk_nodes(
354                    &seq.children,
355                    parallel,
356                    child_ids,
357                    writes,
358                    shell,
359                    network,
360                    secrets,
361                    worktree,
362                );
363            }
364            WorkflowNode::LoopUntil(loop_spec) => {
365                walk_nodes(
366                    &loop_spec.children,
367                    parallel,
368                    child_ids,
369                    writes,
370                    shell,
371                    network,
372                    secrets,
373                    worktree,
374                );
375            }
376            WorkflowNode::Cond(cond) => {
377                walk_nodes(
378                    &cond.then_nodes,
379                    parallel,
380                    child_ids,
381                    writes,
382                    shell,
383                    network,
384                    secrets,
385                    worktree,
386                );
387                walk_nodes(
388                    &cond.else_nodes,
389                    parallel,
390                    child_ids,
391                    writes,
392                    shell,
393                    network,
394                    secrets,
395                    worktree,
396                );
397            }
398            WorkflowNode::Expand(expand) => {
399                if let Some(template) = expand.template.as_deref() {
400                    walk_nodes(
401                        std::slice::from_ref(template),
402                        parallel,
403                        child_ids,
404                        writes,
405                        shell,
406                        network,
407                        secrets,
408                        worktree,
409                    );
410                }
411            }
412            WorkflowNode::Reduce(_) | WorkflowNode::TeacherReview(_) => {
413                // Control/reduce nodes do not spawn write-capable leaves themselves.
414            }
415        }
416    }
417}
418
419#[allow(clippy::too_many_arguments)]
420fn inspect_leaf(
421    leaf: &LeafSpec,
422    parallel: bool,
423    child_ids: &mut Vec<String>,
424    writes: &mut bool,
425    shell: &mut bool,
426    network: &mut bool,
427    secrets: &mut bool,
428    worktree: &mut bool,
429) {
430    child_ids.push(leaf.id.clone());
431    if leaf_is_write_capable(leaf) {
432        *writes = true;
433    }
434    merge_permissions(&leaf.permissions, writes, shell, network, secrets);
435    if leaf_wants_worktree(leaf, parallel) || matches!(leaf.isolation, IsolationMode::Worktree) {
436        *worktree = true;
437    }
438    // Explicit read_write mode with shell tools already handled; implementer
439    // without a tool denylist can run shell.
440    if leaf.mode == TaskMode::ReadWrite
441        && leaf.permissions.allowed_tools.is_empty()
442        && matches!(
443            leaf.agent_type,
444            crate::AgentType::Implementer | crate::AgentType::General
445        )
446    {
447        // Write-capable implementers/general agents may run shell beyond
448        // read-only — flag shell as elevated for the approval card.
449        *shell = true;
450    }
451}
452
453fn merge_permissions(
454    permissions: &PermissionSpec,
455    writes: &mut bool,
456    shell: &mut bool,
457    network: &mut bool,
458    secrets: &mut bool,
459) {
460    if permissions.allow_write {
461        *writes = true;
462    }
463    if permissions.allow_network {
464        *network = true;
465    }
466    for tool in &permissions.allowed_tools {
467        let name = tool.trim();
468        if is_write_tool(name) {
469            *writes = true;
470        }
471        if is_shell_tool(name) {
472            *shell = true;
473        }
474        if is_network_tool(name) {
475            *network = true;
476        }
477        if is_secret_tool(name) {
478            *secrets = true;
479        }
480    }
481}
482
483/// True for a tool that can modify files.
484///
485/// This is the one list. It previously existed twice — here and as half of
486/// the TUI's `is_write_or_shell_tool` — and the two drifted: `Edit`, the
487/// model-visible canonical name for the write tool, was in the TUI copy and
488/// missing here, so a branch or sequence whose `allowed_tools` was `["Edit"]`
489/// produced an approval card reporting `writes: false` for a spec that could
490/// in fact write.
491pub fn is_write_tool(tool: &str) -> bool {
492    matches!(
493        tool.trim(),
494        "Edit" | "write_file" | "edit_file" | "apply_patch" | "checklist_write" | "todo_write"
495    )
496}
497
498/// True for a tool that can run a shell command.
499pub fn is_shell_tool(tool: &str) -> bool {
500    matches!(
501        tool.trim(),
502        "exec_shell"
503            | "exec_shell_wait"
504            | "exec_shell_interact"
505            | "exec_wait"
506            | "exec_interact"
507            | "task_shell_start"
508            | "task_shell_wait"
509    )
510}
511
512fn is_network_tool(tool: &str) -> bool {
513    matches!(
514        tool,
515        "web_search" | "web_run" | "fetch_url" | "wait_for_dev_server"
516    ) || tool.starts_with("mcp_")
517}
518
519fn is_secret_tool(tool: &str) -> bool {
520    let lower = tool.to_ascii_lowercase();
521    lower.contains("secret")
522        || lower.contains("credential")
523        || lower.contains("password")
524        || lower == "read_env"
525        || lower == "env"
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use crate::{
532        AgentType, BranchSpec, BudgetSpec, LeafSpec, ModelPolicy, PermissionSpec, PromotionPolicy,
533        SequenceSpec, TaskMode,
534    };
535
536    fn leaf(id: &str, mode: TaskMode) -> LeafSpec {
537        LeafSpec {
538            id: id.to_string(),
539            prompt: format!("do {id}"),
540            agent_type: if mode == TaskMode::ReadWrite {
541                AgentType::Implementer
542            } else {
543                AgentType::Explore
544            },
545            profile: None,
546            role: None,
547            mode,
548            isolation: IsolationMode::Auto,
549            file_scope: Vec::new(),
550            depends_on_results: Vec::new(),
551            budget: BudgetSpec::default(),
552            permissions: PermissionSpec::default(),
553            model_policy: ModelPolicy::default(),
554        }
555    }
556
557    #[test]
558    fn edit_is_recognized_as_a_write_tool() {
559        // #4730: `Edit` is the model-visible canonical write-tool name. It
560        // lived only in the TUI's copy of this list, so the risk assessor
561        // didn't know it was a write.
562        assert!(is_write_tool("Edit"));
563        assert!(is_write_tool(" Edit "));
564        for tool in [
565            "write_file",
566            "edit_file",
567            "apply_patch",
568            "checklist_write",
569            "todo_write",
570        ] {
571            assert!(is_write_tool(tool), "{tool} must count as a write");
572        }
573        assert!(!is_write_tool("read_file"));
574        assert!(!is_write_tool("Editor"));
575    }
576
577    #[test]
578    fn branch_allowing_edit_reports_writes_in_its_risk_summary() {
579        // The tool-allowlist path is what produces branch/sequence-level
580        // permission summaries; a spec that can write must not present an
581        // approval card saying it cannot.
582        let spec = spec_with(
583            vec![WorkflowNode::BranchSet(BranchSpec {
584                id: "edits".to_string(),
585                description: None,
586                parallel: false,
587                budget: BudgetSpec::default(),
588                permissions: PermissionSpec {
589                    allowed_tools: vec!["Edit".to_string()],
590                    ..PermissionSpec::default()
591                },
592                model_policy: ModelPolicy::default(),
593                children: vec![WorkflowNode::Leaf(leaf("child", TaskMode::ReadOnly))],
594            })],
595            None,
596        );
597
598        let elevation = assess_workflow_elevation(&spec, ElevationOptions::default());
599        assert!(
600            elevation.writes,
601            "branch allowing Edit must report writes: {elevation:?}"
602        );
603    }
604
605    fn spec_with(nodes: Vec<WorkflowNode>, risk: Option<&str>) -> WorkflowSpec {
606        WorkflowSpec {
607            id: Some("test".to_string()),
608            goal: "ship feature".to_string(),
609            description: risk.map(str::to_string),
610            budget: BudgetSpec::default(),
611            permissions: PermissionSpec::default(),
612            model_policy: ModelPolicy::default(),
613            promotion_policy: PromotionPolicy::default(),
614            gates: Vec::new(),
615            nodes,
616        }
617    }
618
619    #[test]
620    fn read_only_plan_is_not_elevated() {
621        let spec = spec_with(
622            vec![WorkflowNode::Leaf(leaf("scan", TaskMode::ReadOnly))],
623            Some("read_only"),
624        );
625        let elevation = assess_workflow_elevation(&spec, ElevationOptions::default());
626        assert!(!elevation.elevated, "{elevation:?}");
627        assert!(elevation.is_read_only_envelope());
628        assert_eq!(elevation.goal, "ship feature");
629        assert!(elevation.child_summary.contains("scan"));
630        assert!(!elevation.writes);
631        assert!(!elevation.shell);
632        assert!(!elevation.network);
633        let fields = elevation.card_fields();
634        assert_eq!(fields.len(), 6);
635        assert!(
636            fields
637                .iter()
638                .any(|(k, v)| *k == "Goal" && v == "ship feature")
639        );
640        assert!(fields.iter().any(|(k, v)| *k == "Writes" && v == "no"));
641    }
642
643    #[test]
644    fn free_form_description_is_not_treated_as_plan_risk() {
645        let spec = spec_with(
646            vec![WorkflowNode::Leaf(leaf("scan", TaskMode::ReadOnly))],
647            Some(
648                "Read-only release acceptance fixture; no step edits files or accesses the network.",
649            ),
650        );
651
652        let elevation = assess_workflow_elevation(&spec, ElevationOptions::default());
653        assert!(elevation.is_read_only_envelope(), "{elevation:?}");
654        assert!(!elevation.writes, "{elevation:?}");
655        assert!(!elevation.shell, "{elevation:?}");
656        assert!(!elevation.network, "{elevation:?}");
657        assert!(elevation.reasons.is_empty(), "{elevation:?}");
658    }
659
660    #[test]
661    fn read_only_implementer_role_is_not_write_capable_or_elevated() {
662        let mut implementer = leaf("verify-only", TaskMode::ReadOnly);
663        implementer.agent_type = AgentType::Implementer;
664        implementer.role = Some("implementer".to_string());
665        let spec = spec_with(
666            vec![WorkflowNode::BranchSet(BranchSpec {
667                id: "parallel-read-only".to_string(),
668                description: None,
669                parallel: true,
670                budget: BudgetSpec::default(),
671                permissions: PermissionSpec::default(),
672                model_policy: ModelPolicy::default(),
673                children: vec![WorkflowNode::Leaf(implementer)],
674            })],
675            Some("read_only"),
676        );
677
678        let elevation = assess_workflow_elevation(&spec, ElevationOptions::default());
679        assert!(elevation.is_read_only_envelope(), "{elevation:?}");
680        assert!(!elevation.elevated, "{elevation:?}");
681        assert!(!elevation.writes, "{elevation:?}");
682        assert!(!elevation.shell, "{elevation:?}");
683        assert!(!elevation.worktree, "{elevation:?}");
684    }
685
686    #[test]
687    fn write_plan_elevates_and_flags_shell_for_implementer() {
688        let spec = spec_with(
689            vec![WorkflowNode::Leaf(leaf("impl", TaskMode::ReadWrite))],
690            Some("writes"),
691        );
692        let elevation = assess_workflow_elevation(&spec, ElevationOptions::default());
693        assert!(elevation.elevated);
694        assert!(elevation.writes);
695        assert!(elevation.shell);
696        assert!(elevation.reasons.iter().any(|r| r == "writes"));
697    }
698
699    #[test]
700    fn network_and_secrets_tools_elevate() {
701        let mut network_leaf = leaf("fetch", TaskMode::ReadOnly);
702        network_leaf.permissions.allow_network = true;
703        network_leaf.permissions.allowed_tools = vec!["fetch_url".to_string()];
704
705        let mut secret_leaf = leaf("creds", TaskMode::ReadOnly);
706        secret_leaf.permissions.allowed_tools = vec!["read_secret".to_string()];
707
708        let spec = spec_with(
709            vec![WorkflowNode::Sequence(SequenceSpec {
710                id: "seq".to_string(),
711                children: vec![
712                    WorkflowNode::Leaf(network_leaf),
713                    WorkflowNode::Leaf(secret_leaf),
714                ],
715            })],
716            None,
717        );
718        let elevation = assess_workflow_elevation(&spec, ElevationOptions::default());
719        assert!(elevation.elevated);
720        assert!(elevation.network);
721        assert!(elevation.secrets);
722        assert!(elevation.reasons.iter().any(|r| r == "network"));
723        assert!(elevation.reasons.iter().any(|r| r == "secrets"));
724    }
725
726    #[test]
727    fn parallel_write_children_flag_worktree() {
728        let left = leaf("left", TaskMode::ReadWrite);
729        let right = leaf("right", TaskMode::ReadWrite);
730        let spec = spec_with(
731            vec![WorkflowNode::BranchSet(BranchSpec {
732                id: "parallel".to_string(),
733                description: None,
734                parallel: true,
735                budget: BudgetSpec::default(),
736                permissions: PermissionSpec::default(),
737                model_policy: ModelPolicy::default(),
738                children: vec![WorkflowNode::Leaf(left), WorkflowNode::Leaf(right)],
739            })],
740            Some("writes"),
741        );
742        let elevation = assess_workflow_elevation(&spec, ElevationOptions::default());
743        assert!(elevation.worktree, "{elevation:?}");
744        assert!(elevation.writes);
745    }
746
747    #[test]
748    fn high_budget_elevates() {
749        let mut spec = spec_with(
750            vec![WorkflowNode::Leaf(leaf("scan", TaskMode::ReadOnly))],
751            Some("read_only"),
752        );
753        spec.budget.max_tokens = Some(250_000);
754        let elevation = assess_workflow_elevation(&spec, ElevationOptions::default());
755        assert!(elevation.high_budget);
756        assert!(elevation.elevated);
757        assert!(elevation.budget_label.contains("high"));
758    }
759
760    #[test]
761    fn broader_authority_when_parent_is_read_only() {
762        let spec = spec_with(
763            vec![WorkflowNode::Leaf(leaf("impl", TaskMode::ReadWrite))],
764            Some("writes"),
765        );
766        let elevation = assess_workflow_elevation(
767            &spec,
768            ElevationOptions {
769                parent_allows_write: false,
770                parent_allows_network: false,
771                ..ElevationOptions::default()
772            },
773        );
774        assert!(elevation.broader_authority);
775        assert!(elevation.reasons.iter().any(|r| r == "broader_authority"));
776    }
777
778    #[test]
779    fn plan_risk_string_classifies_elevated_variants() {
780        assert_eq!(
781            assess_plan_risk_string(Some("read_only")),
782            PlanRiskHint::ReadOnly
783        );
784        assert_eq!(
785            assess_plan_risk_string(Some("writes")),
786            PlanRiskHint::Writes
787        );
788        assert_eq!(assess_plan_risk_string(Some("shell")), PlanRiskHint::Shell);
789        assert_eq!(
790            assess_plan_risk_string(Some("network")),
791            PlanRiskHint::Network
792        );
793        assert_eq!(
794            assess_plan_risk_string(Some("elevated")),
795            PlanRiskHint::Elevated
796        );
797        assert_eq!(
798            assess_plan_risk_string(Some("unknown-risk")),
799            PlanRiskHint::Elevated,
800            "unknown planner risk must remain fail-closed"
801        );
802        assert!(assess_plan_risk_string(Some("elevated")).elevates());
803        assert!(!assess_plan_risk_string(Some("read_only")).elevates());
804    }
805
806    #[test]
807    fn card_fields_always_include_required_labels() {
808        let spec = spec_with(
809            vec![WorkflowNode::Leaf(leaf("a", TaskMode::ReadOnly))],
810            None,
811        );
812        let fields = assess_workflow_elevation(&spec, ElevationOptions::default()).card_fields();
813        let labels: Vec<_> = fields.iter().map(|(k, _)| *k).collect();
814        assert_eq!(
815            labels,
816            vec!["Goal", "Children", "Writes", "Shell", "Network", "Budget"]
817        );
818    }
819}