返回 CodeWhale
workflow_plan_approval.rs
根目录 / crates / tui / src / tools / workflow_plan_approval.rs
1 //! Elevated Workflow plan approval analysis (#4126).
2 //!
3 //! Builds the approval-card summary (goal, children, writes/shell/network/budget)
4 //! and decides whether a launch is elevated enough to require operator approval
5 //! beyond read-only auto-start.
6
7 use codewhale_config::WorkflowConfigToml;
8 use codewhale_workflow::{
9 ElevationOptions, WorkflowPlanElevation, WorkflowSpec, assess_workflow_elevation,
10 };
11 use serde::{Deserialize, Serialize};
12 use serde_json::Value;
13
14 use crate::tools::spec::ApprovalRequirement;
15
16 /// Capability / budget summary shown on the Workflow approval card.
17 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18 pub struct WorkflowPlanApprovalSummary {
19 pub goal: String,
20 pub risk: Option<String>,
21 pub child_count: usize,
22 pub child_labels: Vec<String>,
23 pub child_summary: String,
24 pub phase_count: usize,
25 pub writes: bool,
26 pub shell: bool,
27 pub network: bool,
28 pub secrets: bool,
29 pub worktree: bool,
30 pub high_budget: bool,
31 pub broader_authority: bool,
32 pub token_budget: Option<u64>,
33 pub budget_label: String,
34 pub elevated: bool,
35 pub reasons: Vec<String>,
36 }
37
38 impl WorkflowPlanApprovalSummary {
39 /// Card field pairs: Goal, Children, Writes, Shell, Network, Budget.
40 #[must_use]
41 pub fn card_fields(&self) -> Vec<(&'static str, String)> {
42 vec![
43 ("Goal", self.goal.clone()),
44 ("Children", self.child_summary.clone()),
45 ("Writes", yn(self.writes).to_string()),
46 ("Shell", yn(self.shell).to_string()),
47 ("Network", yn(self.network).to_string()),
48 ("Budget", self.budget_label.clone()),
49 ]
50 }
51
52 /// One-line impacts for the shared ApprovalView card.
53 #[must_use]
54 pub fn approval_impacts(&self) -> Vec<String> {
55 let mut impacts = Vec::new();
56 if !self.goal.is_empty() {
57 impacts.push(format!("Goal: {}", truncate(&self.goal, 96)));
58 }
59 if let Some(risk) = &self.risk {
60 impacts.push(format!("Risk: {risk}"));
61 }
62 impacts.push(format!("Children: {}", self.child_summary));
63 if self.phase_count > 0 {
64 impacts.push(format!("Phases: {}", self.phase_count));
65 }
66 impacts.push(format!("Writes: {}", yn(self.writes)));
67 impacts.push(format!("Shell: {}", yn(self.shell)));
68 impacts.push(format!("Network: {}", yn(self.network)));
69 if self.secrets {
70 impacts.push(format!("Secrets: {}", yn(self.secrets)));
71 }
72 if self.worktree {
73 impacts.push(format!("Worktree: {}", yn(self.worktree)));
74 }
75 impacts.push(format!("Budget: {}", self.budget_label));
76 if self.broader_authority {
77 impacts.push("Broader authority than parent mode".into());
78 }
79 if self.elevated {
80 impacts.push(
81 "Elevated plan — Approve to launch, Edit plan to revise, Cancel to abort.".into(),
82 );
83 } else {
84 impacts.push("Read-only plan.".into());
85 }
86 impacts
87 }
88
89 /// Durable receipt fragment for audit after approval/launch.
90 #[must_use]
91 pub fn to_receipt(&self, decision: &str, approved_at_ms: u64) -> WorkflowPlanApprovalReceipt {
92 WorkflowPlanApprovalReceipt {
93 decision: decision.to_string(),
94 approved_at_ms,
95 goal: self.goal.clone(),
96 child_summary: self.child_summary.clone(),
97 writes: self.writes,
98 shell: self.shell,
99 network: self.network,
100 secrets: self.secrets,
101 worktree: self.worktree,
102 high_budget: self.high_budget,
103 broader_authority: self.broader_authority,
104 budget_label: self.budget_label.clone(),
105 reasons: self.reasons.clone(),
106 elevated: self.elevated,
107 token_budget: self.token_budget,
108 risk: self.risk.clone(),
109 }
110 }
111
112 #[must_use]
113 pub fn is_read_only_envelope(&self) -> bool {
114 !self.elevated
115 }
116 }
117
118 /// Durable snapshot of an approved (or auto-started) plan for audit (#4126).
119 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120 pub struct WorkflowPlanApprovalReceipt {
121 pub decision: String,
122 pub approved_at_ms: u64,
123 pub goal: String,
124 pub child_summary: String,
125 pub writes: bool,
126 pub shell: bool,
127 pub network: bool,
128 pub secrets: bool,
129 pub worktree: bool,
130 pub high_budget: bool,
131 pub broader_authority: bool,
132 pub budget_label: String,
133 pub reasons: Vec<String>,
134 pub elevated: bool,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub token_budget: Option<u64>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub risk: Option<String>,
139 }
140
141 /// Analyze a `workflow` tool input for approval elevation (#4126).
142 #[must_use]
143 pub fn analyze_workflow_plan_approval(input: &Value) -> WorkflowPlanApprovalSummary {
144 analyze_workflow_plan_approval_with_config(input, &WorkflowConfigToml::default())
145 }
146
147 /// Same as [`analyze_workflow_plan_approval`] with an explicit workflow config.
148 #[must_use]
149 pub fn analyze_workflow_plan_approval_with_config(
150 input: &Value,
151 config: &WorkflowConfigToml,
152 ) -> WorkflowPlanApprovalSummary {
153 let action = input
154 .get("action")
155 .and_then(Value::as_str)
156 .unwrap_or("start");
157 if matches!(action, "status" | "cancel") {
158 return empty_summary(format!("workflow {action}"), None);
159 }
160
161 if let Some(plan) = input.get("plan").filter(|v| v.is_object()) {
162 return analyze_plan_object(plan, optional_u64(input, "token_budget"), config);
163 }
164
165 // script / source_path — conservative elevated unless clearly status-only.
166 let goal = input
167 .get("source_path")
168 .and_then(Value::as_str)
169 .map(|p| format!("source_path: {p}"))
170 .or_else(|| {
171 input
172 .get("script")
173 .and_then(Value::as_str)
174 .map(|s| truncate(s.lines().next().unwrap_or("inline script"), 80))
175 })
176 .unwrap_or_else(|| "workflow launch".into());
177
178 let script = input
179 .get("script")
180 .and_then(Value::as_str)
181 .unwrap_or_default();
182 let writes = script_suggests_writes(script);
183 let shell = script_suggests_shell(script);
184 let network = script_suggests_network(script);
185 let worktree = script.contains("worktree") || script.contains("isolation");
186 let token_budget = optional_u64(input, "token_budget");
187 // The card reports what the run will actually get: the caller's budget,
188 // else the configured default when one is set — 0 means none applies.
189 let effective_budget = token_budget
190 .filter(|b| *b > 0)
191 .or((config.default_token_budget > 0).then_some(config.default_token_budget));
192 let high_budget = is_high_budget(token_budget, config);
193 // Unknown script authority: always elevated so the card is required.
194 let elevated = true;
195 let mut reasons = vec!["script_or_source".to_string()];
196 if writes {
197 reasons.push("writes".into());
198 }
199 if shell {
200 reasons.push("shell".into());
201 }
202 if network {
203 reasons.push("network".into());
204 }
205 if worktree {
206 reasons.push("worktree".into());
207 }
208 if high_budget {
209 reasons.push("high_budget".into());
210 }
211 let child_count = count_script_tasks(script);
212 let child_summary = if child_count == 0 {
213 "script/source (authority unknown until run)".into()
214 } else {
215 format!("{child_count} task() calls")
216 };
217 WorkflowPlanApprovalSummary {
218 goal,
219 risk: None,
220 child_count,
221 child_labels: Vec::new(),
222 child_summary,
223 phase_count: script.matches("phase(").count(),
224 writes: writes || elevated,
225 shell: shell || elevated,
226 network: network || elevated,
227 secrets: false,
228 worktree,
229 high_budget,
230 broader_authority: false,
231 token_budget,
232 budget_label: budget_label(effective_budget, high_budget),
233 elevated,
234 reasons,
235 }
236 }
237
238 /// Assess a compiled Workflow IR for the approval card / receipt.
239 #[must_use]
240 pub fn analyze_workflow_spec(
241 spec: &WorkflowSpec,
242 token_budget: Option<u64>,
243 config: &WorkflowConfigToml,
244 ) -> WorkflowPlanApprovalSummary {
245 let elevation = assess_workflow_elevation(
246 spec,
247 ElevationOptions {
248 token_budget,
249 high_budget_threshold: config.default_token_budget,
250 ..ElevationOptions::default()
251 },
252 );
253 summary_from_elevation(elevation, spec.description.clone(), token_budget)
254 }
255
256 fn summary_from_elevation(
257 elevation: WorkflowPlanElevation,
258 risk: Option<String>,
259 token_budget: Option<u64>,
260 ) -> WorkflowPlanApprovalSummary {
261 WorkflowPlanApprovalSummary {
262 goal: elevation.goal,
263 risk,
264 child_count: elevation.child_count,
265 child_labels: Vec::new(),
266 child_summary: elevation.child_summary,
267 phase_count: 0,
268 writes: elevation.writes,
269 shell: elevation.shell,
270 network: elevation.network,
271 secrets: elevation.secrets,
272 worktree: elevation.worktree,
273 high_budget: elevation.high_budget,
274 broader_authority: elevation.broader_authority,
275 token_budget,
276 budget_label: elevation.budget_label,
277 elevated: elevation.elevated,
278 reasons: elevation.reasons,
279 }
280 }
281
282 /// Decide whether the workflow tool call requires an approval card (#4126).
283 #[must_use]
284 pub fn workflow_approval_requirement_for(
285 input: &Value,
286 config: &WorkflowConfigToml,
287 ) -> ApprovalRequirement {
288 let action = input
289 .get("action")
290 .and_then(Value::as_str)
291 .unwrap_or("start");
292 match action {
293 "status" => ApprovalRequirement::Auto,
294 "cancel" => ApprovalRequirement::Required,
295 _ => {
296 let summary = analyze_workflow_plan_approval_with_config(input, config);
297 if summary.is_read_only_envelope() {
298 if config.auto_start_read_only {
299 ApprovalRequirement::Auto
300 } else {
301 ApprovalRequirement::Required
302 }
303 } else if config.require_approval_for_writes {
304 ApprovalRequirement::Required
305 } else {
306 ApprovalRequirement::Auto
307 }
308 }
309 }
310 }
311
312 fn empty_summary(goal: String, token_budget: Option<u64>) -> WorkflowPlanApprovalSummary {
313 WorkflowPlanApprovalSummary {
314 goal,
315 risk: None,
316 child_count: 0,
317 child_labels: Vec::new(),
318 child_summary: "0 children".into(),
319 phase_count: 0,
320 writes: false,
321 shell: false,
322 network: false,
323 secrets: false,
324 worktree: false,
325 high_budget: false,
326 broader_authority: false,
327 token_budget,
328 budget_label: budget_label(token_budget, false),
329 elevated: false,
330 reasons: Vec::new(),
331 }
332 }
333
334 fn analyze_plan_object(
335 plan: &Value,
336 token_budget_override: Option<u64>,
337 config: &WorkflowConfigToml,
338 ) -> WorkflowPlanApprovalSummary {
339 let goal = plan
340 .get("goal")
341 .and_then(Value::as_str)
342 .unwrap_or("")
343 .trim()
344 .to_string();
345 let risk = plan
346 .get("risk")
347 .and_then(Value::as_str)
348 .map(str::trim)
349 .filter(|s| !s.is_empty())
350 .map(str::to_string);
351 // Mirror structured-plan lowering's `plan_risk_to_mode` aliases exactly:
352 // child role identity never elevates an omitted/read-only plan mode.
353 let default_mode_is_write = matches!(
354 risk.as_deref(),
355 Some(
356 "writes"
357 | "write"
358 | "read_write"
359 | "readwrite"
360 | "medium"
361 | "elevated"
362 | "high"
363 | "shell"
364 | "network"
365 )
366 );
367 let token_budget = token_budget_override.or_else(|| {
368 plan.get("token_budget")
369 .and_then(Value::as_u64)
370 .or_else(|| {
371 plan.get("budget")
372 .and_then(|b| b.get("max_tokens"))
373 .and_then(Value::as_u64)
374 })
375 });
376
377 let mut child_labels = Vec::new();
378 let mut child_count = 0usize;
379 let mut phase_count = 0usize;
380 let mut writes = false;
381 let mut shell = false;
382 let mut network = false;
383 let mut secrets = false;
384 let mut worktree = false;
385
386 if let Some(phases) = plan.get("phases").and_then(Value::as_array) {
387 phase_count = phases.len();
388 for phase in phases {
389 collect_children(
390 phase.get("children").and_then(Value::as_array),
391 &mut child_labels,
392 &mut child_count,
393 &mut writes,
394 &mut shell,
395 &mut network,
396 &mut secrets,
397 &mut worktree,
398 default_mode_is_write,
399 );
400 }
401 }
402 collect_children(
403 plan.get("children").and_then(Value::as_array),
404 &mut child_labels,
405 &mut child_count,
406 &mut writes,
407 &mut shell,
408 &mut network,
409 &mut secrets,
410 &mut worktree,
411 default_mode_is_write,
412 );
413 // IR nodes escape hatch
414 if let Some(nodes) = plan.get("nodes").and_then(Value::as_array) {
415 walk_nodes(
416 nodes,
417 &mut child_labels,
418 &mut child_count,
419 &mut phase_count,
420 &mut writes,
421 &mut shell,
422 &mut network,
423 &mut secrets,
424 &mut worktree,
425 default_mode_is_write,
426 );
427 }
428
429 if default_mode_is_write {
430 writes = true;
431 shell = shell || matches!(risk.as_deref(), Some("elevated" | "high" | "shell"));
432 network = network || matches!(risk.as_deref(), Some("elevated" | "high" | "network"));
433 }
434
435 // Parallel write children default to worktree isolation (#4120).
436 if writes && child_count > 1 {
437 worktree = true;
438 }
439
440 let high_budget = is_high_budget(token_budget, config);
441 let mut reasons = Vec::new();
442 if writes {
443 reasons.push("writes".into());
444 }
445 if shell {
446 reasons.push("shell".into());
447 }
448 if network {
449 reasons.push("network".into());
450 }
451 if secrets {
452 reasons.push("secrets".into());
453 }
454 if worktree {
455 reasons.push("worktree".into());
456 }
457 if high_budget {
458 reasons.push("high_budget".into());
459 }
460 let elevated = !reasons.is_empty();
461
462 let child_summary = if child_labels.is_empty() {
463 format!(
464 "{child_count} child{}",
465 if child_count == 1 { "" } else { "ren" }
466 )
467 } else {
468 let shown: Vec<_> = child_labels.iter().take(4).map(String::as_str).collect();
469 let mut line = format!(
470 "{child_count} child{}: {}",
471 if child_count == 1 { "" } else { "ren" },
472 shown.join(", ")
473 );
474 if child_labels.len() > 4 {
475 line.push_str(", …");
476 }
477 line
478 };
479
480 WorkflowPlanApprovalSummary {
481 goal,
482 risk,
483 child_count,
484 child_labels,
485 child_summary,
486 phase_count,
487 writes,
488 shell,
489 network,
490 secrets,
491 worktree,
492 high_budget,
493 broader_authority: false,
494 token_budget,
495 budget_label: budget_label(token_budget, high_budget),
496 elevated,
497 reasons,
498 }
499 }
500
501 #[allow(clippy::too_many_arguments)]
502 fn collect_children(
503 children: Option<&Vec<Value>>,
504 labels: &mut Vec<String>,
505 count: &mut usize,
506 writes: &mut bool,
507 shell: &mut bool,
508 network: &mut bool,
509 secrets: &mut bool,
510 worktree: &mut bool,
511 default_mode_is_write: bool,
512 ) {
513 let Some(children) = children else {
514 return;
515 };
516 for child in children {
517 *count += 1;
518 if let Some(label) = child
519 .get("label")
520 .or_else(|| child.get("id"))
521 .and_then(Value::as_str)
522 {
523 labels.push(label.to_string());
524 }
525 let mode = child
526 .get("mode")
527 .and_then(Value::as_str)
528 .unwrap_or_default()
529 .trim()
530 .to_ascii_lowercase();
531 let agent_type = child
532 .get("type")
533 .or_else(|| child.get("agent_type"))
534 .and_then(Value::as_str)
535 .unwrap_or("general")
536 .trim()
537 .to_ascii_lowercase();
538 let effective_read_write = match mode.as_str() {
539 "read_only" | "readonly" => false,
540 "read_write" | "readwrite" | "writes" | "write" => true,
541 "" => default_mode_is_write,
542 other => other.contains("write") && !other.contains("read_only"),
543 };
544 if effective_read_write {
545 *writes = true;
546 // Write-capable builders/workers may run shell beyond read-only.
547 // Keep the legacy runtime names for stored Workflow plans.
548 if matches!(
549 agent_type.as_str(),
550 "builder" | "implement" | "implementer" | "worker" | "general"
551 ) {
552 *shell = true;
553 }
554 }
555 if child
556 .get("permissions")
557 .and_then(|p| p.get("allow_write"))
558 .and_then(Value::as_bool)
559 == Some(true)
560 {
561 *writes = true;
562 }
563 if child
564 .get("permissions")
565 .and_then(|p| p.get("allow_network"))
566 .and_then(Value::as_bool)
567 == Some(true)
568 {
569 *network = true;
570 }
571 let isolation = child
572 .get("isolation")
573 .and_then(Value::as_str)
574 .unwrap_or_default();
575 if isolation == "worktree" {
576 *worktree = true;
577 }
578 if let Some(tools) = child
579 .get("permissions")
580 .and_then(|p| p.get("allowed_tools"))
581 .and_then(Value::as_array)
582 {
583 for tool in tools {
584 let name = tool.as_str().unwrap_or_default();
585 if name.contains("shell") || name.contains("exec") {
586 *shell = true;
587 }
588 if name.contains("secret") || name.contains("credential") || name == "read_env" {
589 *secrets = true;
590 }
591 if matches!(name, "web_search" | "web_run" | "fetch_url")
592 || name.starts_with("mcp_")
593 {
594 *network = true;
595 }
596 if matches!(
597 name,
598 "write" | "edit" | "write_file" | "edit_file" | "apply_patch"
599 ) {
600 *writes = true;
601 }
602 }
603 }
604 }
605 }
606
607 #[allow(clippy::too_many_arguments)]
608 fn walk_nodes(
609 nodes: &[Value],
610 labels: &mut Vec<String>,
611 count: &mut usize,
612 phase_count: &mut usize,
613 writes: &mut bool,
614 shell: &mut bool,
615 network: &mut bool,
616 secrets: &mut bool,
617 worktree: &mut bool,
618 default_mode_is_write: bool,
619 ) {
620 for node in nodes {
621 if let Some(agent) = node.get("agent") {
622 collect_children(
623 Some(&vec![agent.clone()]),
624 labels,
625 count,
626 writes,
627 shell,
628 network,
629 secrets,
630 worktree,
631 default_mode_is_write,
632 );
633 }
634 if let Some(branch) = node.get("branch") {
635 *phase_count += 1;
636 collect_children(
637 branch.get("children").and_then(Value::as_array),
638 labels,
639 count,
640 writes,
641 shell,
642 network,
643 secrets,
644 worktree,
645 default_mode_is_write,
646 );
647 }
648 if let Some(seq) = node.get("sequence") {
649 *phase_count += 1;
650 if let Some(children) = seq.get("children").and_then(Value::as_array) {
651 walk_nodes(
652 children,
653 labels,
654 count,
655 phase_count,
656 writes,
657 shell,
658 network,
659 secrets,
660 worktree,
661 default_mode_is_write,
662 );
663 }
664 }
665 if let Some(kind) = node.get("kind").and_then(Value::as_str)
666 && kind == "leaf"
667 && let Some(spec) = node.get("spec")
668 {
669 collect_children(
670 Some(&vec![spec.clone()]),
671 labels,
672 count,
673 writes,
674 shell,
675 network,
676 secrets,
677 worktree,
678 default_mode_is_write,
679 );
680 }
681 }
682 }
683
684 fn script_suggests_writes(script: &str) -> bool {
685 let lower = script.to_ascii_lowercase();
686 lower.contains("implementer")
687 || lower.contains("read_write")
688 || lower.contains("allow_write")
689 || lower.contains("write_file")
690 || lower.contains("\"write\"")
691 || lower.contains("\"edit\"")
692 || lower.contains("apply_patch")
693 }
694
695 fn script_suggests_shell(script: &str) -> bool {
696 let lower = script.to_ascii_lowercase();
697 lower.contains("exec_shell")
698 || lower.contains("\"bash\"")
699 || lower.contains("bash(")
700 || ((lower.contains("allowedtools") || lower.contains("allowed_tools"))
701 && (lower.contains("shell") || lower.contains("bash")))
702 }
703
704 fn script_suggests_network(script: &str) -> bool {
705 let lower = script.to_ascii_lowercase();
706 lower.contains("allow_network") || lower.contains("web_search") || lower.contains("fetch_url")
707 }
708
709 fn count_script_tasks(script: &str) -> usize {
710 script.matches("task(").count()
711 }
712
713 fn optional_u64(value: &Value, key: &str) -> Option<u64> {
714 value.get(key).and_then(Value::as_u64)
715 }
716
717 /// A budget is "high" only against a configured baseline; when
718 /// `[workflow].default_token_budget` is 0 (the default) nothing is high.
719 fn is_high_budget(token_budget: Option<u64>, config: &WorkflowConfigToml) -> bool {
720 config.default_token_budget > 0 && token_budget.is_some_and(|b| b > config.default_token_budget)
721 }
722
723 fn budget_label(token_budget: Option<u64>, high_budget: bool) -> String {
724 match token_budget {
725 Some(n) if high_budget => format!("{n} tokens (high)"),
726 Some(n) => format!("{n} tokens"),
727 None => "unbounded".to_string(),
728 }
729 }
730
731 fn yn(v: bool) -> &'static str {
732 if v { "yes" } else { "no" }
733 }
734
735 fn truncate(s: &str, max: usize) -> String {
736 let s = s.trim();
737 if s.chars().count() <= max {
738 s.to_string()
739 } else {
740 let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
741 out.push('…');
742 out
743 }
744 }
745
746 #[cfg(test)]
747 mod tests {
748 use super::*;
749 use serde_json::json;
750
751 fn config() -> WorkflowConfigToml {
752 WorkflowConfigToml::default()
753 }
754
755 #[test]
756 fn read_only_plan_is_not_elevated_and_auto_starts() {
757 let input = json!({
758 "action": "start",
759 "plan": {
760 "goal": "scout crates",
761 "risk": "read_only",
762 "token_budget": 50000,
763 "phases": [{
764 "id": "scout",
765 "children": [
766 { "id": "a", "prompt": "look left", "type": "explore" },
767 { "id": "b", "prompt": "look right", "type": "explore" }
768 ]
769 }]
770 }
771 });
772 let summary = analyze_workflow_plan_approval(&input);
773 assert!(!summary.elevated, "{summary:?}");
774 assert_eq!(summary.child_count, 2);
775 assert_eq!(summary.phase_count, 1);
776 assert!(!summary.writes);
777 assert_eq!(
778 workflow_approval_requirement_for(&input, &config()),
779 ApprovalRequirement::Auto
780 );
781 let fields = summary.card_fields();
782 assert_eq!(fields.len(), 6);
783 assert!(
784 fields
785 .iter()
786 .any(|(k, v)| *k == "Goal" && v.contains("scout"))
787 );
788 assert!(fields.iter().any(|(k, v)| *k == "Writes" && v == "no"));
789 assert!(fields.iter().any(|(k, v)| *k == "Shell" && v == "no"));
790 assert!(fields.iter().any(|(k, v)| *k == "Network" && v == "no"));
791 assert!(fields.iter().any(|(k, _)| *k == "Children"));
792 assert!(fields.iter().any(|(k, _)| *k == "Budget"));
793 let impacts = summary.approval_impacts();
794 assert!(impacts.iter().any(|i| i.contains("Goal: scout")));
795 assert!(impacts.iter().any(|i| i.contains("Writes: no")));
796 }
797
798 #[test]
799 fn write_plan_is_elevated_with_card_fields_and_requires_approval() {
800 let input = json!({
801 "action": "start",
802 "plan": {
803 "goal": "land the fix",
804 "risk": "writes",
805 "token_budget": 120000,
806 "children": [
807 {
808 "id": "builder",
809 "label": "impl",
810 "prompt": "patch it",
811 "type": "implementer",
812 "mode": "read_write"
813 }
814 ]
815 }
816 });
817 let summary = analyze_workflow_plan_approval(&input);
818 assert!(summary.elevated);
819 assert!(summary.writes);
820 assert!(summary.shell);
821 assert_eq!(
822 workflow_approval_requirement_for(&input, &config()),
823 ApprovalRequirement::Required
824 );
825 let fields = summary.card_fields();
826 assert!(fields.iter().any(|(k, v)| *k == "Writes" && v == "yes"));
827 assert!(fields.iter().any(|(k, v)| *k == "Shell" && v == "yes"));
828 assert!(
829 fields
830 .iter()
831 .any(|(k, v)| *k == "Budget" && v.contains("120000"))
832 );
833 let impacts = summary.approval_impacts();
834 assert!(impacts.iter().any(|i| i.contains("Writes: yes")));
835 assert!(impacts.iter().any(|i| i.contains("Approve to launch")));
836 let receipt = summary.to_receipt("approved", 99);
837 assert_eq!(receipt.decision, "approved");
838 assert_eq!(receipt.approved_at_ms, 99);
839 assert_eq!(receipt.goal, "land the fix");
840 assert!(receipt.elevated);
841 assert!(receipt.writes);
842 }
843
844 #[test]
845 fn canonical_worker_role_flags_shell_for_write_plan() {
846 let input = json!({
847 "action": "start",
848 "plan": {
849 "goal": "land the fix",
850 "children": [{
851 "prompt": "patch it",
852 "type": "worker",
853 "mode": "read_write"
854 }]
855 }
856 });
857
858 let summary = analyze_workflow_plan_approval(&input);
859 assert!(summary.writes);
860 assert!(summary.shell);
861 }
862
863 #[test]
864 fn lowercase_bash_is_classified_as_shell_authority() {
865 for script in [
866 r#"{"allowed_tools":["bash"]}"#,
867 r#"bash({"command":"pwd"})"#,
868 ] {
869 assert!(script_suggests_shell(script), "{script}");
870 }
871 }
872
873 #[test]
874 fn read_only_implementer_does_not_request_write_or_shell_authority() {
875 for child in [
876 json!({
877 "prompt": "review an implementation",
878 "type": "implementer",
879 "mode": "read_only"
880 }),
881 json!({
882 "prompt": "review under the plan envelope",
883 "type": "implementer"
884 }),
885 ] {
886 let summary = analyze_workflow_plan_approval(&json!({
887 "plan": {
888 "goal": "read-only implementation review",
889 "risk": "read_only",
890 "children": [child]
891 }
892 }));
893 assert!(!summary.writes, "{summary:?}");
894 assert!(!summary.shell, "{summary:?}");
895 assert!(!summary.elevated, "{summary:?}");
896 }
897
898 let omitted_risk = analyze_workflow_plan_approval(&json!({
899 "plan": {
900 "goal": "default-safe implementation review",
901 "children": [{ "prompt": "inspect", "type": "implementer" }]
902 }
903 }));
904 assert!(!omitted_risk.writes, "{omitted_risk:?}");
905 assert!(!omitted_risk.shell, "{omitted_risk:?}");
906
907 for risk in ["medium", "readwrite"] {
908 let write_default = analyze_workflow_plan_approval(&json!({
909 "plan": {
910 "goal": "default writer",
911 "risk": risk,
912 "children": [{ "prompt": "patch" }]
913 }
914 }));
915 assert!(write_default.writes, "{risk}: {write_default:?}");
916 assert!(write_default.shell, "{risk}: {write_default:?}");
917 }
918 }
919
920 #[test]
921 fn elevated_risk_flags_shell_and_network() {
922 let summary = analyze_workflow_plan_approval(&json!({
923 "plan": {
924 "goal": "full authority",
925 "risk": "elevated",
926 "children": [{ "prompt": "go", "type": "implementer" }]
927 }
928 }));
929 assert!(summary.elevated);
930 assert!(summary.writes);
931 assert!(summary.shell);
932 assert!(summary.network);
933 let fields = summary.card_fields();
934 assert!(fields.iter().any(|(k, v)| *k == "Network" && v == "yes"));
935 }
936
937 #[test]
938 fn high_budget_elevates_read_only_plan() {
939 let input = json!({
940 "action": "start",
941 "plan": {
942 "goal": "huge scout",
943 "risk": "read_only",
944 "token_budget": 250_000,
945 "children": [{ "prompt": "scan", "type": "explore" }]
946 }
947 });
948 // With no configured baseline (the default) nothing is "high" — the
949 // flag only exists relative to an operator-set cap (#6189).
950 let uncapped = analyze_workflow_plan_approval(&input);
951 assert!(!uncapped.high_budget, "{uncapped:?}");
952 let config = WorkflowConfigToml {
953 default_token_budget: 120_000,
954 ..WorkflowConfigToml::default()
955 };
956 let summary = analyze_workflow_plan_approval_with_config(&input, &config);
957 assert!(summary.high_budget, "{summary:?}");
958 assert!(summary.elevated);
959 assert!(summary.budget_label.contains("high"));
960 assert_eq!(
961 workflow_approval_requirement_for(&input, &config),
962 ApprovalRequirement::Required
963 );
964 }
965
966 #[test]
967 fn secrets_and_network_tools_elevate() {
968 let summary = analyze_workflow_plan_approval(&json!({
969 "plan": {
970 "goal": "creds",
971 "risk": "read_only",
972 "children": [{
973 "id": "s",
974 "prompt": "read secrets",
975 "type": "explore",
976 "permissions": {
977 "allow_network": true,
978 "allowed_tools": ["read_secret", "fetch_url"]
979 }
980 }]
981 }
982 }));
983 assert!(summary.elevated);
984 assert!(summary.secrets);
985 assert!(summary.network);
986 }
987
988 #[test]
989 fn status_is_auto_cancel_is_required() {
990 assert_eq!(
991 workflow_approval_requirement_for(&json!({"action": "status"}), &config()),
992 ApprovalRequirement::Auto
993 );
994 assert_eq!(
995 workflow_approval_requirement_for(
996 &json!({"action": "cancel", "run_id": "x"}),
997 &config()
998 ),
999 ApprovalRequirement::Required
1000 );
1001 }
1002
1003 #[test]
1004 fn require_approval_for_writes_true_blocks_write_start_read_only_stays_auto() {
1005 let mut cfg = config();
1006 cfg.require_approval_for_writes = true;
1007 cfg.auto_start_read_only = true;
1008 let write_plan = json!({
1009 "action": "start",
1010 "plan": {
1011 "goal": "land the fix",
1012 "risk": "writes",
1013 "children": [{
1014 "prompt": "patch it",
1015 "type": "implementer",
1016 "mode": "read_write"
1017 }]
1018 }
1019 });
1020 let read_only = json!({
1021 "action": "start",
1022 "plan": {
1023 "goal": "scout crates",
1024 "risk": "read_only",
1025 "children": [{ "prompt": "look", "type": "explore" }]
1026 }
1027 });
1028 assert_eq!(
1029 workflow_approval_requirement_for(&write_plan, &cfg),
1030 ApprovalRequirement::Required,
1031 "require_approval_for_writes = true must require the card for a write start"
1032 );
1033 assert_eq!(
1034 workflow_approval_requirement_for(&read_only, &cfg),
1035 ApprovalRequirement::Auto,
1036 "auto_start_read_only = true must still auto-start a read-only plan"
1037 );
1038 }
1039
1040 #[test]
1041 fn require_approval_for_writes_false_allows_elevated_auto() {
1042 let mut cfg = config();
1043 cfg.require_approval_for_writes = false;
1044 let input = json!({
1045 "action": "start",
1046 "plan": {
1047 "goal": "write freely",
1048 "risk": "writes",
1049 "children": [{ "prompt": "edit", "type": "implementer" }]
1050 }
1051 });
1052 assert_eq!(
1053 workflow_approval_requirement_for(&input, &cfg),
1054 ApprovalRequirement::Auto
1055 );
1056 }
1057
1058 #[test]
1059 fn script_launch_requires_approval() {
1060 assert_eq!(
1061 workflow_approval_requirement_for(
1062 &json!({"action": "start", "script": "return 1;"}),
1063 &config()
1064 ),
1065 ApprovalRequirement::Required
1066 );
1067 }
1068
1069 #[test]
1070 fn card_fields_always_six_required_labels() {
1071 let summary = analyze_workflow_plan_approval(&json!({
1072 "plan": {
1073 "goal": "x",
1074 "risk": "read_only",
1075 "children": [{ "prompt": "y", "type": "explore" }]
1076 }
1077 }));
1078 let labels: Vec<_> = summary.card_fields().iter().map(|(k, _)| *k).collect();
1079 assert_eq!(
1080 labels,
1081 vec!["Goal", "Children", "Writes", "Shell", "Network", "Budget"]
1082 );
1083 }
1084 }
1085
1085 lines RUST