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
483fn is_write_tool(tool: &str) -> bool {
484    matches!(
485        tool,
486        "write_file" | "edit_file" | "apply_patch" | "checklist_write" | "todo_write"
487    )
488}
489
490fn is_shell_tool(tool: &str) -> bool {
491    matches!(
492        tool,
493        "exec_shell"
494            | "exec_shell_wait"
495            | "exec_shell_interact"
496            | "exec_wait"
497            | "exec_interact"
498            | "task_shell_start"
499            | "task_shell_wait"
500    )
501}
502
503fn is_network_tool(tool: &str) -> bool {
504    matches!(
505        tool,
506        "web_search" | "web_run" | "fetch_url" | "wait_for_dev_server"
507    ) || tool.starts_with("mcp_")
508}
509
510fn is_secret_tool(tool: &str) -> bool {
511    let lower = tool.to_ascii_lowercase();
512    lower.contains("secret")
513        || lower.contains("credential")
514        || lower.contains("password")
515        || lower == "read_env"
516        || lower == "env"
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use crate::{
523        AgentType, BranchSpec, BudgetSpec, LeafSpec, ModelPolicy, PermissionSpec, PromotionPolicy,
524        SequenceSpec, TaskMode,
525    };
526
527    fn leaf(id: &str, mode: TaskMode) -> LeafSpec {
528        LeafSpec {
529            id: id.to_string(),
530            prompt: format!("do {id}"),
531            agent_type: if mode == TaskMode::ReadWrite {
532                AgentType::Implementer
533            } else {
534                AgentType::Explore
535            },
536            profile: None,
537            role: None,
538            mode,
539            isolation: IsolationMode::Auto,
540            file_scope: Vec::new(),
541            depends_on_results: Vec::new(),
542            budget: BudgetSpec::default(),
543            permissions: PermissionSpec::default(),
544            model_policy: ModelPolicy::default(),
545        }
546    }
547
548    fn spec_with(nodes: Vec<WorkflowNode>, risk: Option<&str>) -> WorkflowSpec {
549        WorkflowSpec {
550            id: Some("test".to_string()),
551            goal: "ship feature".to_string(),
552            description: risk.map(str::to_string),
553            budget: BudgetSpec::default(),
554            permissions: PermissionSpec::default(),
555            model_policy: ModelPolicy::default(),
556            promotion_policy: PromotionPolicy::default(),
557            gates: Vec::new(),
558            nodes,
559        }
560    }
561
562    #[test]
563    fn read_only_plan_is_not_elevated() {
564        let spec = spec_with(
565            vec![WorkflowNode::Leaf(leaf("scan", TaskMode::ReadOnly))],
566            Some("read_only"),
567        );
568        let elevation = assess_workflow_elevation(&spec, ElevationOptions::default());
569        assert!(!elevation.elevated, "{elevation:?}");
570        assert!(elevation.is_read_only_envelope());
571        assert_eq!(elevation.goal, "ship feature");
572        assert!(elevation.child_summary.contains("scan"));
573        assert!(!elevation.writes);
574        assert!(!elevation.shell);
575        assert!(!elevation.network);
576        let fields = elevation.card_fields();
577        assert_eq!(fields.len(), 6);
578        assert!(
579            fields
580                .iter()
581                .any(|(k, v)| *k == "Goal" && v == "ship feature")
582        );
583        assert!(fields.iter().any(|(k, v)| *k == "Writes" && v == "no"));
584    }
585
586    #[test]
587    fn free_form_description_is_not_treated_as_plan_risk() {
588        let spec = spec_with(
589            vec![WorkflowNode::Leaf(leaf("scan", TaskMode::ReadOnly))],
590            Some(
591                "Read-only release acceptance fixture; no step edits files or accesses the network.",
592            ),
593        );
594
595        let elevation = assess_workflow_elevation(&spec, ElevationOptions::default());
596        assert!(elevation.is_read_only_envelope(), "{elevation:?}");
597        assert!(!elevation.writes, "{elevation:?}");
598        assert!(!elevation.shell, "{elevation:?}");
599        assert!(!elevation.network, "{elevation:?}");
600        assert!(elevation.reasons.is_empty(), "{elevation:?}");
601    }
602
603    #[test]
604    fn read_only_implementer_role_is_not_write_capable_or_elevated() {
605        let mut implementer = leaf("verify-only", TaskMode::ReadOnly);
606        implementer.agent_type = AgentType::Implementer;
607        implementer.role = Some("implementer".to_string());
608        let spec = spec_with(
609            vec![WorkflowNode::BranchSet(BranchSpec {
610                id: "parallel-read-only".to_string(),
611                description: None,
612                parallel: true,
613                budget: BudgetSpec::default(),
614                permissions: PermissionSpec::default(),
615                model_policy: ModelPolicy::default(),
616                children: vec![WorkflowNode::Leaf(implementer)],
617            })],
618            Some("read_only"),
619        );
620
621        let elevation = assess_workflow_elevation(&spec, ElevationOptions::default());
622        assert!(elevation.is_read_only_envelope(), "{elevation:?}");
623        assert!(!elevation.elevated, "{elevation:?}");
624        assert!(!elevation.writes, "{elevation:?}");
625        assert!(!elevation.shell, "{elevation:?}");
626        assert!(!elevation.worktree, "{elevation:?}");
627    }
628
629    #[test]
630    fn write_plan_elevates_and_flags_shell_for_implementer() {
631        let spec = spec_with(
632            vec![WorkflowNode::Leaf(leaf("impl", TaskMode::ReadWrite))],
633            Some("writes"),
634        );
635        let elevation = assess_workflow_elevation(&spec, ElevationOptions::default());
636        assert!(elevation.elevated);
637        assert!(elevation.writes);
638        assert!(elevation.shell);
639        assert!(elevation.reasons.iter().any(|r| r == "writes"));
640    }
641
642    #[test]
643    fn network_and_secrets_tools_elevate() {
644        let mut network_leaf = leaf("fetch", TaskMode::ReadOnly);
645        network_leaf.permissions.allow_network = true;
646        network_leaf.permissions.allowed_tools = vec!["fetch_url".to_string()];
647
648        let mut secret_leaf = leaf("creds", TaskMode::ReadOnly);
649        secret_leaf.permissions.allowed_tools = vec!["read_secret".to_string()];
650
651        let spec = spec_with(
652            vec![WorkflowNode::Sequence(SequenceSpec {
653                id: "seq".to_string(),
654                children: vec![
655                    WorkflowNode::Leaf(network_leaf),
656                    WorkflowNode::Leaf(secret_leaf),
657                ],
658            })],
659            None,
660        );
661        let elevation = assess_workflow_elevation(&spec, ElevationOptions::default());
662        assert!(elevation.elevated);
663        assert!(elevation.network);
664        assert!(elevation.secrets);
665        assert!(elevation.reasons.iter().any(|r| r == "network"));
666        assert!(elevation.reasons.iter().any(|r| r == "secrets"));
667    }
668
669    #[test]
670    fn parallel_write_children_flag_worktree() {
671        let left = leaf("left", TaskMode::ReadWrite);
672        let right = leaf("right", TaskMode::ReadWrite);
673        let spec = spec_with(
674            vec![WorkflowNode::BranchSet(BranchSpec {
675                id: "parallel".to_string(),
676                description: None,
677                parallel: true,
678                budget: BudgetSpec::default(),
679                permissions: PermissionSpec::default(),
680                model_policy: ModelPolicy::default(),
681                children: vec![WorkflowNode::Leaf(left), WorkflowNode::Leaf(right)],
682            })],
683            Some("writes"),
684        );
685        let elevation = assess_workflow_elevation(&spec, ElevationOptions::default());
686        assert!(elevation.worktree, "{elevation:?}");
687        assert!(elevation.writes);
688    }
689
690    #[test]
691    fn high_budget_elevates() {
692        let mut spec = spec_with(
693            vec![WorkflowNode::Leaf(leaf("scan", TaskMode::ReadOnly))],
694            Some("read_only"),
695        );
696        spec.budget.max_tokens = Some(250_000);
697        let elevation = assess_workflow_elevation(&spec, ElevationOptions::default());
698        assert!(elevation.high_budget);
699        assert!(elevation.elevated);
700        assert!(elevation.budget_label.contains("high"));
701    }
702
703    #[test]
704    fn broader_authority_when_parent_is_read_only() {
705        let spec = spec_with(
706            vec![WorkflowNode::Leaf(leaf("impl", TaskMode::ReadWrite))],
707            Some("writes"),
708        );
709        let elevation = assess_workflow_elevation(
710            &spec,
711            ElevationOptions {
712                parent_allows_write: false,
713                parent_allows_network: false,
714                ..ElevationOptions::default()
715            },
716        );
717        assert!(elevation.broader_authority);
718        assert!(elevation.reasons.iter().any(|r| r == "broader_authority"));
719    }
720
721    #[test]
722    fn plan_risk_string_classifies_elevated_variants() {
723        assert_eq!(
724            assess_plan_risk_string(Some("read_only")),
725            PlanRiskHint::ReadOnly
726        );
727        assert_eq!(
728            assess_plan_risk_string(Some("writes")),
729            PlanRiskHint::Writes
730        );
731        assert_eq!(assess_plan_risk_string(Some("shell")), PlanRiskHint::Shell);
732        assert_eq!(
733            assess_plan_risk_string(Some("network")),
734            PlanRiskHint::Network
735        );
736        assert_eq!(
737            assess_plan_risk_string(Some("elevated")),
738            PlanRiskHint::Elevated
739        );
740        assert_eq!(
741            assess_plan_risk_string(Some("unknown-risk")),
742            PlanRiskHint::Elevated,
743            "unknown planner risk must remain fail-closed"
744        );
745        assert!(assess_plan_risk_string(Some("elevated")).elevates());
746        assert!(!assess_plan_risk_string(Some("read_only")).elevates());
747    }
748
749    #[test]
750    fn card_fields_always_include_required_labels() {
751        let spec = spec_with(
752            vec![WorkflowNode::Leaf(leaf("a", TaskMode::ReadOnly))],
753            None,
754        );
755        let fields = assess_workflow_elevation(&spec, ElevationOptions::default()).card_fields();
756        let labels: Vec<_> = fields.iter().map(|(k, _)| *k).collect();
757        assert_eq!(
758            labels,
759            vec!["Goal", "Children", "Writes", "Shell", "Network", "Budget"]
760        );
761    }
762}