返回 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 let high_budget = token_budget.is_some_and(|b| b > config.default_token_budget);
188 // Unknown script authority: always elevated so the card is required.
189 let elevated = true;
190 let mut reasons = vec!["script_or_source".to_string()];
191 if writes {
192 reasons.push("writes".into());
193 }
194 if shell {
195 reasons.push("shell".into());
196 }
197 if network {
198 reasons.push("network".into());
199 }
200 if worktree {
201 reasons.push("worktree".into());
202 }
203 if high_budget {
204 reasons.push("high_budget".into());
205 }
206 let child_count = count_script_tasks(script);
207 let child_summary = if child_count == 0 {
208 "script/source (authority unknown until run)".into()
209 } else {
210 format!("{child_count} task() calls")
211 };
212 WorkflowPlanApprovalSummary {
213 goal,
214 risk: None,
215 child_count,
216 child_labels: Vec::new(),
217 child_summary,
218 phase_count: script.matches("phase(").count(),
219 writes: writes || elevated,
220 shell: shell || elevated,
221 network: network || elevated,
222 secrets: false,
223 worktree,
224 high_budget,
225 broader_authority: false,
226 token_budget,
227 budget_label: budget_label(token_budget, high_budget),
228 elevated,
229 reasons,
230 }
231 }
232
233 /// Assess a compiled Workflow IR for the approval card / receipt.
234 #[must_use]
235 pub fn analyze_workflow_spec(
236 spec: &WorkflowSpec,
237 token_budget: Option<u64>,
238 config: &WorkflowConfigToml,
239 ) -> WorkflowPlanApprovalSummary {
240 let elevation = assess_workflow_elevation(
241 spec,
242 ElevationOptions {
243 token_budget,
244 high_budget_threshold: config.default_token_budget,
245 ..ElevationOptions::default()
246 },
247 );
248 summary_from_elevation(elevation, spec.description.clone(), token_budget)
249 }
250
251 fn summary_from_elevation(
252 elevation: WorkflowPlanElevation,
253 risk: Option<String>,
254 token_budget: Option<u64>,
255 ) -> WorkflowPlanApprovalSummary {
256 WorkflowPlanApprovalSummary {
257 goal: elevation.goal,
258 risk,
259 child_count: elevation.child_count,
260 child_labels: Vec::new(),
261 child_summary: elevation.child_summary,
262 phase_count: 0,
263 writes: elevation.writes,
264 shell: elevation.shell,
265 network: elevation.network,
266 secrets: elevation.secrets,
267 worktree: elevation.worktree,
268 high_budget: elevation.high_budget,
269 broader_authority: elevation.broader_authority,
270 token_budget,
271 budget_label: elevation.budget_label,
272 elevated: elevation.elevated,
273 reasons: elevation.reasons,
274 }
275 }
276
277 /// Decide whether the workflow tool call requires an approval card (#4126).
278 #[must_use]
279 pub fn workflow_approval_requirement_for(
280 input: &Value,
281 config: &WorkflowConfigToml,
282 ) -> ApprovalRequirement {
283 let action = input
284 .get("action")
285 .and_then(Value::as_str)
286 .unwrap_or("start");
287 match action {
288 "status" => ApprovalRequirement::Auto,
289 "cancel" => ApprovalRequirement::Required,
290 _ => {
291 let summary = analyze_workflow_plan_approval_with_config(input, config);
292 if summary.is_read_only_envelope() {
293 if config.auto_start_read_only {
294 ApprovalRequirement::Auto
295 } else {
296 ApprovalRequirement::Required
297 }
298 } else if config.require_approval_for_writes {
299 ApprovalRequirement::Required
300 } else {
301 ApprovalRequirement::Auto
302 }
303 }
304 }
305 }
306
307 fn empty_summary(goal: String, token_budget: Option<u64>) -> WorkflowPlanApprovalSummary {
308 WorkflowPlanApprovalSummary {
309 goal,
310 risk: None,
311 child_count: 0,
312 child_labels: Vec::new(),
313 child_summary: "0 children".into(),
314 phase_count: 0,
315 writes: false,
316 shell: false,
317 network: false,
318 secrets: false,
319 worktree: false,
320 high_budget: false,
321 broader_authority: false,
322 token_budget,
323 budget_label: budget_label(token_budget, false),
324 elevated: false,
325 reasons: Vec::new(),
326 }
327 }
328
329 fn analyze_plan_object(
330 plan: &Value,
331 token_budget_override: Option<u64>,
332 config: &WorkflowConfigToml,
333 ) -> WorkflowPlanApprovalSummary {
334 let goal = plan
335 .get("goal")
336 .and_then(Value::as_str)
337 .unwrap_or("")
338 .trim()
339 .to_string();
340 let risk = plan
341 .get("risk")
342 .and_then(Value::as_str)
343 .map(str::trim)
344 .filter(|s| !s.is_empty())
345 .map(str::to_string);
346 // Mirror structured-plan lowering's `plan_risk_to_mode` aliases exactly:
347 // child role identity never elevates an omitted/read-only plan mode.
348 let default_mode_is_write = matches!(
349 risk.as_deref(),
350 Some(
351 "writes"
352 | "write"
353 | "read_write"
354 | "readwrite"
355 | "medium"
356 | "elevated"
357 | "high"
358 | "shell"
359 | "network"
360 )
361 );
362 let token_budget = token_budget_override.or_else(|| {
363 plan.get("token_budget")
364 .and_then(Value::as_u64)
365 .or_else(|| {
366 plan.get("budget")
367 .and_then(|b| b.get("max_tokens"))
368 .and_then(Value::as_u64)
369 })
370 });
371
372 let mut child_labels = Vec::new();
373 let mut child_count = 0usize;
374 let mut phase_count = 0usize;
375 let mut writes = false;
376 let mut shell = false;
377 let mut network = false;
378 let mut secrets = false;
379 let mut worktree = false;
380
381 if let Some(phases) = plan.get("phases").and_then(Value::as_array) {
382 phase_count = phases.len();
383 for phase in phases {
384 collect_children(
385 phase.get("children").and_then(Value::as_array),
386 &mut child_labels,
387 &mut child_count,
388 &mut writes,
389 &mut shell,
390 &mut network,
391 &mut secrets,
392 &mut worktree,
393 default_mode_is_write,
394 );
395 }
396 }
397 collect_children(
398 plan.get("children").and_then(Value::as_array),
399 &mut child_labels,
400 &mut child_count,
401 &mut writes,
402 &mut shell,
403 &mut network,
404 &mut secrets,
405 &mut worktree,
406 default_mode_is_write,
407 );
408 // IR nodes escape hatch
409 if let Some(nodes) = plan.get("nodes").and_then(Value::as_array) {
410 walk_nodes(
411 nodes,
412 &mut child_labels,
413 &mut child_count,
414 &mut phase_count,
415 &mut writes,
416 &mut shell,
417 &mut network,
418 &mut secrets,
419 &mut worktree,
420 default_mode_is_write,
421 );
422 }
423
424 if default_mode_is_write {
425 writes = true;
426 shell = shell || matches!(risk.as_deref(), Some("elevated" | "high" | "shell"));
427 network = network || matches!(risk.as_deref(), Some("elevated" | "high" | "network"));
428 }
429
430 // Parallel write children default to worktree isolation (#4120).
431 if writes && child_count > 1 {
432 worktree = true;
433 }
434
435 let high_budget = token_budget.is_some_and(|b| b > config.default_token_budget);
436 let mut reasons = Vec::new();
437 if writes {
438 reasons.push("writes".into());
439 }
440 if shell {
441 reasons.push("shell".into());
442 }
443 if network {
444 reasons.push("network".into());
445 }
446 if secrets {
447 reasons.push("secrets".into());
448 }
449 if worktree {
450 reasons.push("worktree".into());
451 }
452 if high_budget {
453 reasons.push("high_budget".into());
454 }
455 let elevated = !reasons.is_empty();
456
457 let child_summary = if child_labels.is_empty() {
458 format!(
459 "{child_count} child{}",
460 if child_count == 1 { "" } else { "ren" }
461 )
462 } else {
463 let shown: Vec<_> = child_labels.iter().take(4).map(String::as_str).collect();
464 let mut line = format!(
465 "{child_count} child{}: {}",
466 if child_count == 1 { "" } else { "ren" },
467 shown.join(", ")
468 );
469 if child_labels.len() > 4 {
470 line.push_str(", …");
471 }
472 line
473 };
474
475 WorkflowPlanApprovalSummary {
476 goal,
477 risk,
478 child_count,
479 child_labels,
480 child_summary,
481 phase_count,
482 writes,
483 shell,
484 network,
485 secrets,
486 worktree,
487 high_budget,
488 broader_authority: false,
489 token_budget,
490 budget_label: budget_label(token_budget, high_budget),
491 elevated,
492 reasons,
493 }
494 }
495
496 #[allow(clippy::too_many_arguments)]
497 fn collect_children(
498 children: Option<&Vec<Value>>,
499 labels: &mut Vec<String>,
500 count: &mut usize,
501 writes: &mut bool,
502 shell: &mut bool,
503 network: &mut bool,
504 secrets: &mut bool,
505 worktree: &mut bool,
506 default_mode_is_write: bool,
507 ) {
508 let Some(children) = children else {
509 return;
510 };
511 for child in children {
512 *count += 1;
513 if let Some(label) = child
514 .get("label")
515 .or_else(|| child.get("id"))
516 .and_then(Value::as_str)
517 {
518 labels.push(label.to_string());
519 }
520 let mode = child
521 .get("mode")
522 .and_then(Value::as_str)
523 .unwrap_or_default()
524 .trim()
525 .to_ascii_lowercase();
526 let agent_type = child
527 .get("type")
528 .or_else(|| child.get("agent_type"))
529 .and_then(Value::as_str)
530 .unwrap_or("general")
531 .trim()
532 .to_ascii_lowercase();
533 let effective_read_write = match mode.as_str() {
534 "read_only" | "readonly" => false,
535 "read_write" | "readwrite" | "writes" | "write" => true,
536 "" => default_mode_is_write,
537 other => other.contains("write") && !other.contains("read_only"),
538 };
539 if effective_read_write {
540 *writes = true;
541 // Write-capable builders/workers may run shell beyond read-only.
542 // Keep the legacy runtime names for stored Workflow plans.
543 if matches!(
544 agent_type.as_str(),
545 "builder" | "implement" | "implementer" | "worker" | "general"
546 ) {
547 *shell = true;
548 }
549 }
550 if child
551 .get("permissions")
552 .and_then(|p| p.get("allow_write"))
553 .and_then(Value::as_bool)
554 == Some(true)
555 {
556 *writes = true;
557 }
558 if child
559 .get("permissions")
560 .and_then(|p| p.get("allow_network"))
561 .and_then(Value::as_bool)
562 == Some(true)
563 {
564 *network = true;
565 }
566 let isolation = child
567 .get("isolation")
568 .and_then(Value::as_str)
569 .unwrap_or_default();
570 if isolation == "worktree" {
571 *worktree = true;
572 }
573 if let Some(tools) = child
574 .get("permissions")
575 .and_then(|p| p.get("allowed_tools"))
576 .and_then(Value::as_array)
577 {
578 for tool in tools {
579 let name = tool.as_str().unwrap_or_default();
580 if name.contains("shell") || name.contains("exec") {
581 *shell = true;
582 }
583 if name.contains("secret") || name.contains("credential") || name == "read_env" {
584 *secrets = true;
585 }
586 if matches!(name, "web_search" | "web_run" | "fetch_url")
587 || name.starts_with("mcp_")
588 {
589 *network = true;
590 }
591 if matches!(name, "write_file" | "edit_file" | "apply_patch") {
592 *writes = true;
593 }
594 }
595 }
596 }
597 }
598
599 #[allow(clippy::too_many_arguments)]
600 fn walk_nodes(
601 nodes: &[Value],
602 labels: &mut Vec<String>,
603 count: &mut usize,
604 phase_count: &mut usize,
605 writes: &mut bool,
606 shell: &mut bool,
607 network: &mut bool,
608 secrets: &mut bool,
609 worktree: &mut bool,
610 default_mode_is_write: bool,
611 ) {
612 for node in nodes {
613 if let Some(agent) = node.get("agent") {
614 collect_children(
615 Some(&vec![agent.clone()]),
616 labels,
617 count,
618 writes,
619 shell,
620 network,
621 secrets,
622 worktree,
623 default_mode_is_write,
624 );
625 }
626 if let Some(branch) = node.get("branch") {
627 *phase_count += 1;
628 collect_children(
629 branch.get("children").and_then(Value::as_array),
630 labels,
631 count,
632 writes,
633 shell,
634 network,
635 secrets,
636 worktree,
637 default_mode_is_write,
638 );
639 }
640 if let Some(seq) = node.get("sequence") {
641 *phase_count += 1;
642 if let Some(children) = seq.get("children").and_then(Value::as_array) {
643 walk_nodes(
644 children,
645 labels,
646 count,
647 phase_count,
648 writes,
649 shell,
650 network,
651 secrets,
652 worktree,
653 default_mode_is_write,
654 );
655 }
656 }
657 if let Some(kind) = node.get("kind").and_then(Value::as_str)
658 && kind == "leaf"
659 && let Some(spec) = node.get("spec")
660 {
661 collect_children(
662 Some(&vec![spec.clone()]),
663 labels,
664 count,
665 writes,
666 shell,
667 network,
668 secrets,
669 worktree,
670 default_mode_is_write,
671 );
672 }
673 }
674 }
675
676 fn script_suggests_writes(script: &str) -> bool {
677 let lower = script.to_ascii_lowercase();
678 lower.contains("implementer")
679 || lower.contains("read_write")
680 || lower.contains("allow_write")
681 || lower.contains("write_file")
682 || lower.contains("apply_patch")
683 }
684
685 fn script_suggests_shell(script: &str) -> bool {
686 let lower = script.to_ascii_lowercase();
687 lower.contains("exec_shell") || (lower.contains("allowedtools") && lower.contains("shell"))
688 }
689
690 fn script_suggests_network(script: &str) -> bool {
691 let lower = script.to_ascii_lowercase();
692 lower.contains("allow_network") || lower.contains("web_search") || lower.contains("fetch_url")
693 }
694
695 fn count_script_tasks(script: &str) -> usize {
696 script.matches("task(").count()
697 }
698
699 fn optional_u64(value: &Value, key: &str) -> Option<u64> {
700 value.get(key).and_then(Value::as_u64)
701 }
702
703 fn budget_label(token_budget: Option<u64>, high_budget: bool) -> String {
704 match token_budget {
705 Some(n) if high_budget => format!("{n} tokens (high)"),
706 Some(n) => format!("{n} tokens"),
707 None => "default".to_string(),
708 }
709 }
710
711 fn yn(v: bool) -> &'static str {
712 if v { "yes" } else { "no" }
713 }
714
715 fn truncate(s: &str, max: usize) -> String {
716 let s = s.trim();
717 if s.chars().count() <= max {
718 s.to_string()
719 } else {
720 let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
721 out.push('…');
722 out
723 }
724 }
725
726 #[cfg(test)]
727 mod tests {
728 use super::*;
729 use serde_json::json;
730
731 fn config() -> WorkflowConfigToml {
732 WorkflowConfigToml::default()
733 }
734
735 #[test]
736 fn read_only_plan_is_not_elevated_and_auto_starts() {
737 let input = json!({
738 "action": "start",
739 "plan": {
740 "goal": "scout crates",
741 "risk": "read_only",
742 "token_budget": 50000,
743 "phases": [{
744 "id": "scout",
745 "children": [
746 { "id": "a", "prompt": "look left", "type": "explore" },
747 { "id": "b", "prompt": "look right", "type": "explore" }
748 ]
749 }]
750 }
751 });
752 let summary = analyze_workflow_plan_approval(&input);
753 assert!(!summary.elevated, "{summary:?}");
754 assert_eq!(summary.child_count, 2);
755 assert_eq!(summary.phase_count, 1);
756 assert!(!summary.writes);
757 assert_eq!(
758 workflow_approval_requirement_for(&input, &config()),
759 ApprovalRequirement::Auto
760 );
761 let fields = summary.card_fields();
762 assert_eq!(fields.len(), 6);
763 assert!(
764 fields
765 .iter()
766 .any(|(k, v)| *k == "Goal" && v.contains("scout"))
767 );
768 assert!(fields.iter().any(|(k, v)| *k == "Writes" && v == "no"));
769 assert!(fields.iter().any(|(k, v)| *k == "Shell" && v == "no"));
770 assert!(fields.iter().any(|(k, v)| *k == "Network" && v == "no"));
771 assert!(fields.iter().any(|(k, _)| *k == "Children"));
772 assert!(fields.iter().any(|(k, _)| *k == "Budget"));
773 let impacts = summary.approval_impacts();
774 assert!(impacts.iter().any(|i| i.contains("Goal: scout")));
775 assert!(impacts.iter().any(|i| i.contains("Writes: no")));
776 }
777
778 #[test]
779 fn write_plan_is_elevated_with_card_fields_and_requires_approval() {
780 let input = json!({
781 "action": "start",
782 "plan": {
783 "goal": "land the fix",
784 "risk": "writes",
785 "token_budget": 120000,
786 "children": [
787 {
788 "id": "builder",
789 "label": "impl",
790 "prompt": "patch it",
791 "type": "implementer",
792 "mode": "read_write"
793 }
794 ]
795 }
796 });
797 let summary = analyze_workflow_plan_approval(&input);
798 assert!(summary.elevated);
799 assert!(summary.writes);
800 assert!(summary.shell);
801 assert_eq!(
802 workflow_approval_requirement_for(&input, &config()),
803 ApprovalRequirement::Required
804 );
805 let fields = summary.card_fields();
806 assert!(fields.iter().any(|(k, v)| *k == "Writes" && v == "yes"));
807 assert!(fields.iter().any(|(k, v)| *k == "Shell" && v == "yes"));
808 assert!(
809 fields
810 .iter()
811 .any(|(k, v)| *k == "Budget" && v.contains("120000"))
812 );
813 let impacts = summary.approval_impacts();
814 assert!(impacts.iter().any(|i| i.contains("Writes: yes")));
815 assert!(impacts.iter().any(|i| i.contains("Approve to launch")));
816 let receipt = summary.to_receipt("approved", 99);
817 assert_eq!(receipt.decision, "approved");
818 assert_eq!(receipt.approved_at_ms, 99);
819 assert_eq!(receipt.goal, "land the fix");
820 assert!(receipt.elevated);
821 assert!(receipt.writes);
822 }
823
824 #[test]
825 fn canonical_worker_role_flags_shell_for_write_plan() {
826 let input = json!({
827 "action": "start",
828 "plan": {
829 "goal": "land the fix",
830 "children": [{
831 "prompt": "patch it",
832 "type": "worker",
833 "mode": "read_write"
834 }]
835 }
836 });
837
838 let summary = analyze_workflow_plan_approval(&input);
839 assert!(summary.writes);
840 assert!(summary.shell);
841 }
842
843 #[test]
844 fn read_only_implementer_does_not_request_write_or_shell_authority() {
845 for child in [
846 json!({
847 "prompt": "review an implementation",
848 "type": "implementer",
849 "mode": "read_only"
850 }),
851 json!({
852 "prompt": "review under the plan envelope",
853 "type": "implementer"
854 }),
855 ] {
856 let summary = analyze_workflow_plan_approval(&json!({
857 "plan": {
858 "goal": "read-only implementation review",
859 "risk": "read_only",
860 "children": [child]
861 }
862 }));
863 assert!(!summary.writes, "{summary:?}");
864 assert!(!summary.shell, "{summary:?}");
865 assert!(!summary.elevated, "{summary:?}");
866 }
867
868 let omitted_risk = analyze_workflow_plan_approval(&json!({
869 "plan": {
870 "goal": "default-safe implementation review",
871 "children": [{ "prompt": "inspect", "type": "implementer" }]
872 }
873 }));
874 assert!(!omitted_risk.writes, "{omitted_risk:?}");
875 assert!(!omitted_risk.shell, "{omitted_risk:?}");
876
877 for risk in ["medium", "readwrite"] {
878 let write_default = analyze_workflow_plan_approval(&json!({
879 "plan": {
880 "goal": "default writer",
881 "risk": risk,
882 "children": [{ "prompt": "patch" }]
883 }
884 }));
885 assert!(write_default.writes, "{risk}: {write_default:?}");
886 assert!(write_default.shell, "{risk}: {write_default:?}");
887 }
888 }
889
890 #[test]
891 fn elevated_risk_flags_shell_and_network() {
892 let summary = analyze_workflow_plan_approval(&json!({
893 "plan": {
894 "goal": "full authority",
895 "risk": "elevated",
896 "children": [{ "prompt": "go", "type": "implementer" }]
897 }
898 }));
899 assert!(summary.elevated);
900 assert!(summary.writes);
901 assert!(summary.shell);
902 assert!(summary.network);
903 let fields = summary.card_fields();
904 assert!(fields.iter().any(|(k, v)| *k == "Network" && v == "yes"));
905 }
906
907 #[test]
908 fn high_budget_elevates_read_only_plan() {
909 let input = json!({
910 "action": "start",
911 "plan": {
912 "goal": "huge scout",
913 "risk": "read_only",
914 "token_budget": 250_000,
915 "children": [{ "prompt": "scan", "type": "explore" }]
916 }
917 });
918 let summary = analyze_workflow_plan_approval(&input);
919 assert!(summary.high_budget, "{summary:?}");
920 assert!(summary.elevated);
921 assert!(summary.budget_label.contains("high"));
922 assert_eq!(
923 workflow_approval_requirement_for(&input, &config()),
924 ApprovalRequirement::Required
925 );
926 }
927
928 #[test]
929 fn secrets_and_network_tools_elevate() {
930 let summary = analyze_workflow_plan_approval(&json!({
931 "plan": {
932 "goal": "creds",
933 "risk": "read_only",
934 "children": [{
935 "id": "s",
936 "prompt": "read secrets",
937 "type": "explore",
938 "permissions": {
939 "allow_network": true,
940 "allowed_tools": ["read_secret", "fetch_url"]
941 }
942 }]
943 }
944 }));
945 assert!(summary.elevated);
946 assert!(summary.secrets);
947 assert!(summary.network);
948 }
949
950 #[test]
951 fn status_is_auto_cancel_is_required() {
952 assert_eq!(
953 workflow_approval_requirement_for(&json!({"action": "status"}), &config()),
954 ApprovalRequirement::Auto
955 );
956 assert_eq!(
957 workflow_approval_requirement_for(
958 &json!({"action": "cancel", "run_id": "x"}),
959 &config()
960 ),
961 ApprovalRequirement::Required
962 );
963 }
964
965 #[test]
966 fn require_approval_for_writes_false_allows_elevated_auto() {
967 let mut cfg = config();
968 cfg.require_approval_for_writes = false;
969 let input = json!({
970 "action": "start",
971 "plan": {
972 "goal": "write freely",
973 "risk": "writes",
974 "children": [{ "prompt": "edit", "type": "implementer" }]
975 }
976 });
977 assert_eq!(
978 workflow_approval_requirement_for(&input, &cfg),
979 ApprovalRequirement::Auto
980 );
981 }
982
983 #[test]
984 fn script_launch_requires_approval() {
985 assert_eq!(
986 workflow_approval_requirement_for(
987 &json!({"action": "start", "script": "return 1;"}),
988 &config()
989 ),
990 ApprovalRequirement::Required
991 );
992 }
993
994 #[test]
995 fn card_fields_always_six_required_labels() {
996 let summary = analyze_workflow_plan_approval(&json!({
997 "plan": {
998 "goal": "x",
999 "risk": "read_only",
1000 "children": [{ "prompt": "y", "type": "explore" }]
1001 }
1002 }));
1003 let labels: Vec<_> = summary.card_fields().iter().map(|(k, _)| *k).collect();
1004 assert_eq!(
1005 labels,
1006 vec!["Goal", "Children", "Writes", "Shell", "Network", "Budget"]
1007 );
1008 }
1009 }
1010
1010 lines RUST