返回 CodeWhale
elevation.rs
根目录 / crates / workflow / src / 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
7 use serde::{Deserialize, Serialize};
8
9 use 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.
16 pub 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)]
20 pub 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
31 impl 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)]
45 pub 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
63 impl 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
91 fn 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]
101 pub 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]
216 pub 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)]
232 pub enum PlanRiskHint {
233 ReadOnly,
234 Writes,
235 Shell,
236 Network,
237 Elevated,
238 }
239
240 impl PlanRiskHint {
241 #[must_use]
242 pub fn elevates(self) -> bool {
243 !matches!(self, Self::ReadOnly)
244 }
245 }
246
247 fn 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
271 fn 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
294 fn 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)]
322 fn 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)]
420 fn 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
453 fn 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.
491 pub 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.
499 pub 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
512 fn 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
519 fn 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)]
529 mod 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 }
820
820 lines RUST