1use serde::{Deserialize, Serialize};
8
9use crate::{
10 IsolationMode, LeafSpec, PermissionSpec, TaskMode, WorkflowNode, WorkflowSpec,
11 leaf_is_write_capable, leaf_wants_worktree,
12};
13
14pub const DEFAULT_HIGH_BUDGET_THRESHOLD: u64 = 120_000;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct ElevationOptions {
21 pub token_budget: Option<u64>,
23 pub high_budget_threshold: u64,
25 pub parent_allows_write: bool,
27 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 parent_allows_write: true,
38 parent_allows_network: true,
39 }
40 }
41}
42
43#[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 pub budget_label: String,
59 pub reasons: Vec<String>,
61}
62
63impl WorkflowPlanElevation {
64 #[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 #[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#[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 false,
115 &mut child_ids,
116 &mut writes,
117 &mut shell,
118 &mut network,
119 &mut secrets,
120 &mut worktree,
121 );
122
123 merge_permissions(
125 &spec.permissions,
126 &mut writes,
127 &mut shell,
128 &mut network,
129 &mut secrets,
130 );
131
132 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#[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#[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 }
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 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 *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}