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