返回 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 let mut summary = analyze_workflow_launch(input, config);
162 // `verify: true` runs the workspace's detected gates (package scripts,
163 // `cargo check` build scripts, ...) when the run completes. That is shell
164 // execution of workspace code, so it leaves the read-only envelope.
165 if input.get("verify").and_then(Value::as_bool) == Some(true) {
166 summary.shell = true;
167 summary.elevated = true;
168 if !summary.reasons.iter().any(|r| r == "verify_gates") {
169 summary.reasons.push("verify_gates".into());
170 }
171 }
172 summary
173 }
174
175 fn analyze_workflow_launch(
176 input: &Value,
177 config: &WorkflowConfigToml,
178 ) -> WorkflowPlanApprovalSummary {
179 if let Some(plan) = input.get("plan").filter(|v| v.is_object()) {
180 return analyze_plan_object(plan, optional_u64(input, "token_budget"), config);
181 }
182
183 // script / source_path — conservative elevated unless clearly status-only.
184 let goal = input
185 .get("source_path")
186 .and_then(Value::as_str)
187 .map(|p| format!("source_path: {p}"))
188 .or_else(|| {
189 input
190 .get("script")
191 .and_then(Value::as_str)
192 .map(|s| truncate(s.lines().next().unwrap_or("inline script"), 80))
193 })
194 .unwrap_or_else(|| "workflow launch".into());
195
196 let script = input
197 .get("script")
198 .and_then(Value::as_str)
199 .unwrap_or_default();
200 let writes = script_suggests_writes(script);
201 let shell = script_suggests_shell(script);
202 let network = script_suggests_network(script);
203 let worktree = script.contains("worktree") || script.contains("isolation");
204 let token_budget = optional_u64(input, "token_budget");
205 // The card reports what the run will actually get: the caller's budget,
206 // else the configured default when one is set — 0 means none applies.
207 let effective_budget = token_budget
208 .filter(|b| *b > 0)
209 .or((config.default_token_budget > 0).then_some(config.default_token_budget));
210 let high_budget = is_high_budget(token_budget, config);
211 // Unknown script authority: always elevated so the card is required.
212 let elevated = true;
213 let mut reasons = vec!["script_or_source".to_string()];
214 if writes {
215 reasons.push("writes".into());
216 }
217 if shell {
218 reasons.push("shell".into());
219 }
220 if network {
221 reasons.push("network".into());
222 }
223 if worktree {
224 reasons.push("worktree".into());
225 }
226 if high_budget {
227 reasons.push("high_budget".into());
228 }
229 let child_count = count_script_tasks(script);
230 let child_summary = if child_count == 0 {
231 "script/source (authority unknown until run)".into()
232 } else {
233 format!("{child_count} task() calls")
234 };
235 WorkflowPlanApprovalSummary {
236 goal,
237 risk: None,
238 child_count,
239 child_labels: Vec::new(),
240 child_summary,
241 phase_count: script.matches("phase(").count(),
242 writes: writes || elevated,
243 shell: shell || elevated,
244 network: network || elevated,
245 secrets: false,
246 worktree,
247 high_budget,
248 broader_authority: false,
249 token_budget,
250 budget_label: budget_label(effective_budget, high_budget),
251 elevated,
252 reasons,
253 }
254 }
255
256 /// Assess a compiled Workflow IR for the approval card / receipt.
257 #[must_use]
258 pub fn analyze_workflow_spec(
259 spec: &WorkflowSpec,
260 token_budget: Option<u64>,
261 config: &WorkflowConfigToml,
262 ) -> WorkflowPlanApprovalSummary {
263 let elevation = assess_workflow_elevation(
264 spec,
265 ElevationOptions {
266 token_budget,
267 high_budget_threshold: config.default_token_budget,
268 ..ElevationOptions::default()
269 },
270 );
271 summary_from_elevation(elevation, spec.description.clone(), token_budget)
272 }
273
274 fn summary_from_elevation(
275 elevation: WorkflowPlanElevation,
276 risk: Option<String>,
277 token_budget: Option<u64>,
278 ) -> WorkflowPlanApprovalSummary {
279 WorkflowPlanApprovalSummary {
280 goal: elevation.goal,
281 risk,
282 child_count: elevation.child_count,
283 child_labels: Vec::new(),
284 child_summary: elevation.child_summary,
285 phase_count: 0,
286 writes: elevation.writes,
287 shell: elevation.shell,
288 network: elevation.network,
289 secrets: elevation.secrets,
290 worktree: elevation.worktree,
291 high_budget: elevation.high_budget,
292 broader_authority: elevation.broader_authority,
293 token_budget,
294 budget_label: elevation.budget_label,
295 elevated: elevation.elevated,
296 reasons: elevation.reasons,
297 }
298 }
299
300 /// Decide whether the workflow tool call requires an approval card (#4126).
301 #[must_use]
302 pub fn workflow_approval_requirement_for(
303 input: &Value,
304 config: &WorkflowConfigToml,
305 ) -> ApprovalRequirement {
306 let action = input
307 .get("action")
308 .and_then(Value::as_str)
309 .unwrap_or("start");
310 match action {
311 "status" => ApprovalRequirement::Auto,
312 "cancel" => ApprovalRequirement::Required,
313 _ => {
314 let summary = analyze_workflow_plan_approval_with_config(input, config);
315 if summary.is_read_only_envelope() {
316 if config.auto_start_read_only {
317 ApprovalRequirement::Auto
318 } else {
319 ApprovalRequirement::Required
320 }
321 } else if config.require_approval_for_writes {
322 ApprovalRequirement::Required
323 } else {
324 ApprovalRequirement::Auto
325 }
326 }
327 }
328 }
329
330 fn empty_summary(goal: String, token_budget: Option<u64>) -> WorkflowPlanApprovalSummary {
331 WorkflowPlanApprovalSummary {
332 goal,
333 risk: None,
334 child_count: 0,
335 child_labels: Vec::new(),
336 child_summary: "0 children".into(),
337 phase_count: 0,
338 writes: false,
339 shell: false,
340 network: false,
341 secrets: false,
342 worktree: false,
343 high_budget: false,
344 broader_authority: false,
345 token_budget,
346 budget_label: budget_label(token_budget, false),
347 elevated: false,
348 reasons: Vec::new(),
349 }
350 }
351
352 fn analyze_plan_object(
353 plan: &Value,
354 token_budget_override: Option<u64>,
355 config: &WorkflowConfigToml,
356 ) -> WorkflowPlanApprovalSummary {
357 let goal = plan
358 .get("goal")
359 .and_then(Value::as_str)
360 .unwrap_or("")
361 .trim()
362 .to_string();
363 let risk = plan
364 .get("risk")
365 .and_then(Value::as_str)
366 .map(str::trim)
367 .filter(|s| !s.is_empty())
368 .map(str::to_string);
369 // Mirror structured-plan lowering's `plan_risk_to_mode` aliases exactly:
370 // child role identity never elevates an omitted/read-only plan mode.
371 let default_mode_is_write = matches!(
372 risk.as_deref(),
373 Some(
374 "writes"
375 | "write"
376 | "read_write"
377 | "readwrite"
378 | "medium"
379 | "elevated"
380 | "high"
381 | "shell"
382 | "network"
383 )
384 );
385 let token_budget = token_budget_override.or_else(|| {
386 plan.get("token_budget")
387 .and_then(Value::as_u64)
388 .or_else(|| {
389 plan.get("budget")
390 .and_then(|b| b.get("max_tokens"))
391 .and_then(Value::as_u64)
392 })
393 });
394
395 let mut child_labels = Vec::new();
396 let mut child_count = 0usize;
397 let mut phase_count = 0usize;
398 let mut writes = false;
399 let mut shell = false;
400 let mut network = false;
401 let mut secrets = false;
402 let mut worktree = false;
403
404 if let Some(phases) = plan.get("phases").and_then(Value::as_array) {
405 phase_count = phases.len();
406 for phase in phases {
407 collect_children(
408 phase.get("children").and_then(Value::as_array),
409 &mut child_labels,
410 &mut child_count,
411 &mut writes,
412 &mut shell,
413 &mut network,
414 &mut secrets,
415 &mut worktree,
416 default_mode_is_write,
417 );
418 }
419 }
420 collect_children(
421 plan.get("children").and_then(Value::as_array),
422 &mut child_labels,
423 &mut child_count,
424 &mut writes,
425 &mut shell,
426 &mut network,
427 &mut secrets,
428 &mut worktree,
429 default_mode_is_write,
430 );
431 // IR nodes escape hatch
432 if let Some(nodes) = plan.get("nodes").and_then(Value::as_array) {
433 walk_nodes(
434 nodes,
435 &mut child_labels,
436 &mut child_count,
437 &mut phase_count,
438 &mut writes,
439 &mut shell,
440 &mut network,
441 &mut secrets,
442 &mut worktree,
443 default_mode_is_write,
444 );
445 }
446
447 if default_mode_is_write {
448 writes = true;
449 shell = shell || matches!(risk.as_deref(), Some("elevated" | "high" | "shell"));
450 network = network || matches!(risk.as_deref(), Some("elevated" | "high" | "network"));
451 }
452
453 // Parallel write children default to worktree isolation (#4120).
454 if writes && child_count > 1 {
455 worktree = true;
456 }
457
458 let high_budget = is_high_budget(token_budget, config);
459 let mut reasons = Vec::new();
460 if writes {
461 reasons.push("writes".into());
462 }
463 if shell {
464 reasons.push("shell".into());
465 }
466 if network {
467 reasons.push("network".into());
468 }
469 if secrets {
470 reasons.push("secrets".into());
471 }
472 if worktree {
473 reasons.push("worktree".into());
474 }
475 if high_budget {
476 reasons.push("high_budget".into());
477 }
478 let elevated = !reasons.is_empty();
479
480 let child_summary = if child_labels.is_empty() {
481 format!(
482 "{child_count} child{}",
483 if child_count == 1 { "" } else { "ren" }
484 )
485 } else {
486 let shown: Vec<_> = child_labels.iter().take(4).map(String::as_str).collect();
487 let mut line = format!(
488 "{child_count} child{}: {}",
489 if child_count == 1 { "" } else { "ren" },
490 shown.join(", ")
491 );
492 if child_labels.len() > 4 {
493 line.push_str(", …");
494 }
495 line
496 };
497
498 WorkflowPlanApprovalSummary {
499 goal,
500 risk,
501 child_count,
502 child_labels,
503 child_summary,
504 phase_count,
505 writes,
506 shell,
507 network,
508 secrets,
509 worktree,
510 high_budget,
511 broader_authority: false,
512 token_budget,
513 budget_label: budget_label(token_budget, high_budget),
514 elevated,
515 reasons,
516 }
517 }
518
519 #[allow(clippy::too_many_arguments)]
520 fn collect_children(
521 children: Option<&Vec<Value>>,
522 labels: &mut Vec<String>,
523 count: &mut usize,
524 writes: &mut bool,
525 shell: &mut bool,
526 network: &mut bool,
527 secrets: &mut bool,
528 worktree: &mut bool,
529 default_mode_is_write: bool,
530 ) {
531 let Some(children) = children else {
532 return;
533 };
534 for child in children {
535 *count += 1;
536 if let Some(label) = child
537 .get("label")
538 .or_else(|| child.get("id"))
539 .and_then(Value::as_str)
540 {
541 labels.push(label.to_string());
542 }
543 let mode = child
544 .get("mode")
545 .and_then(Value::as_str)
546 .unwrap_or_default()
547 .trim()
548 .to_ascii_lowercase();
549 let agent_type = child
550 .get("type")
551 .or_else(|| child.get("agent_type"))
552 .and_then(Value::as_str)
553 .unwrap_or("general")
554 .trim()
555 .to_ascii_lowercase();
556 let effective_read_write = match mode.as_str() {
557 "read_only" | "readonly" => false,
558 "read_write" | "readwrite" | "writes" | "write" => true,
559 "" => default_mode_is_write,
560 other => other.contains("write") && !other.contains("read_only"),
561 };
562 if effective_read_write {
563 *writes = true;
564 // Write-capable builders/workers may run shell beyond read-only.
565 // Keep the legacy runtime names for stored Workflow plans.
566 if matches!(
567 agent_type.as_str(),
568 "builder" | "implement" | "implementer" | "worker" | "general"
569 ) {
570 *shell = true;
571 }
572 }
573 if child
574 .get("permissions")
575 .and_then(|p| p.get("allow_write"))
576 .and_then(Value::as_bool)
577 == Some(true)
578 {
579 *writes = true;
580 }
581 if child
582 .get("permissions")
583 .and_then(|p| p.get("allow_network"))
584 .and_then(Value::as_bool)
585 == Some(true)
586 {
587 *network = true;
588 }
589 let isolation = child
590 .get("isolation")
591 .and_then(Value::as_str)
592 .unwrap_or_default();
593 if isolation == "worktree" {
594 *worktree = true;
595 }
596 if let Some(tools) = child
597 .get("permissions")
598 .and_then(|p| p.get("allowed_tools"))
599 .and_then(Value::as_array)
600 {
601 for tool in tools {
602 let authority = tool_name_authority(tool.as_str().unwrap_or_default());
603 *shell |= authority.shell;
604 *network |= authority.network;
605 *writes |= authority.writes;
606 *secrets |= authority.secrets;
607 }
608 }
609 }
610 }
611
612 /// What an allowed-tool entry lets a child do.
613 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
614 struct ToolAuthority {
615 shell: bool,
616 network: bool,
617 writes: bool,
618 secrets: bool,
619 }
620
621 /// Classify one allowed-tool name, as a plan's `allowed_tools` or a script's
622 /// quoted tool name spells it.
623 ///
624 /// Names match case-insensitively (the model-facing families are spelled
625 /// `Bash`, `Web`, `File`, `Run`, `Git`), and a pattern suffix such as
626 /// `Web(*)` or `Bash(git status)` names the same family.
627 fn tool_name_authority(raw: &str) -> ToolAuthority {
628 let name = raw
629 .split('(')
630 .next()
631 .unwrap_or_default()
632 .trim()
633 .to_ascii_lowercase();
634 let name = name.as_str();
635 let mut authority = ToolAuthority::default();
636 if name.contains("shell")
637 || name.contains("exec")
638 || name.contains("bash")
639 || name == "run"
640 || name.starts_with("run_")
641 // `rlm` evaluates code as well as fetching.
642 || name == "rlm"
643 {
644 authority.shell = true;
645 }
646 if name.contains("secret") || name.contains("credential") || name == "read_env" {
647 authority.secrets = true;
648 }
649 if matches!(
650 name,
651 "web" | "web_search" | "web_run" | "web.run" | "fetch_url" | "rlm" | "git_fetch"
652 ) || name.starts_with("mcp_")
653 {
654 authority.network = true;
655 }
656 // `File` carries write/edit/patch actions as well as reads; an
657 // allow-list entry for the family grants all of them.
658 if matches!(
659 name,
660 "file" | "write" | "edit" | "write_file" | "edit_file" | "apply_patch"
661 ) {
662 authority.writes = true;
663 }
664 // Git fetches and commits; the GitHub tools comment, close and publish.
665 if matches!(name, "git" | "github" | "gh") || name.starts_with("github_") {
666 authority.network = true;
667 authority.writes = true;
668 }
669 // Computer and browser control act on apps and pages directly.
670 if name.starts_with("computer") || name.starts_with("browser") {
671 authority.network = true;
672 authority.writes = true;
673 }
674 if name.starts_with("computer") {
675 authority.shell = true;
676 }
677 // Tasks and automations start delegated work that runs on its own.
678 if matches!(
679 name,
680 "task" | "tasks" | "task_create" | "automation" | "automations" | "automation_create"
681 ) {
682 authority.shell = true;
683 authority.writes = true;
684 }
685 authority
686 }
687
688 /// Tool names a script quotes, in any quote style (`"Web"`, `'Web'`,
689 /// `` `Web` ``), combined. Only identifier-like literals count, so prompt
690 /// text in the script is not read as a tool name.
691 fn script_tool_authority(script: &str) -> ToolAuthority {
692 let mut authority = ToolAuthority::default();
693 let mut chars = script.chars();
694 while let Some(c) = chars.next() {
695 if !matches!(c, '"' | '\'' | '`') {
696 continue;
697 }
698 let mut literal = String::new();
699 let mut escaped = false;
700 for next in chars.by_ref() {
701 if escaped {
702 escaped = false;
703 } else if next == '\\' {
704 escaped = true;
705 } else if next == c {
706 break;
707 }
708 literal.push(next);
709 }
710 let looks_like_tool = !literal.is_empty()
711 && literal.len() <= 64
712 && literal
713 .chars()
714 .next()
715 .is_some_and(|first| first.is_ascii_alphabetic())
716 && literal.chars().all(|ch| {
717 ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-' | '(' | ')' | '*' | ' ')
718 });
719 if looks_like_tool {
720 let found = tool_name_authority(&literal);
721 authority.shell |= found.shell;
722 authority.network |= found.network;
723 authority.writes |= found.writes;
724 authority.secrets |= found.secrets;
725 }
726 }
727 authority
728 }
729
730 #[allow(clippy::too_many_arguments)]
731 fn walk_nodes(
732 nodes: &[Value],
733 labels: &mut Vec<String>,
734 count: &mut usize,
735 phase_count: &mut usize,
736 writes: &mut bool,
737 shell: &mut bool,
738 network: &mut bool,
739 secrets: &mut bool,
740 worktree: &mut bool,
741 default_mode_is_write: bool,
742 ) {
743 for node in nodes {
744 if let Some(agent) = node.get("agent") {
745 collect_children(
746 Some(&vec![agent.clone()]),
747 labels,
748 count,
749 writes,
750 shell,
751 network,
752 secrets,
753 worktree,
754 default_mode_is_write,
755 );
756 }
757 if let Some(branch) = node.get("branch") {
758 *phase_count += 1;
759 collect_children(
760 branch.get("children").and_then(Value::as_array),
761 labels,
762 count,
763 writes,
764 shell,
765 network,
766 secrets,
767 worktree,
768 default_mode_is_write,
769 );
770 }
771 if let Some(seq) = node.get("sequence") {
772 *phase_count += 1;
773 if let Some(children) = seq.get("children").and_then(Value::as_array) {
774 walk_nodes(
775 children,
776 labels,
777 count,
778 phase_count,
779 writes,
780 shell,
781 network,
782 secrets,
783 worktree,
784 default_mode_is_write,
785 );
786 }
787 }
788 if let Some(kind) = node.get("kind").and_then(Value::as_str)
789 && kind == "leaf"
790 && let Some(spec) = node.get("spec")
791 {
792 collect_children(
793 Some(&vec![spec.clone()]),
794 labels,
795 count,
796 writes,
797 shell,
798 network,
799 secrets,
800 worktree,
801 default_mode_is_write,
802 );
803 }
804 }
805 }
806
807 fn script_suggests_writes(script: &str) -> bool {
808 let lower = script.to_ascii_lowercase();
809 lower.contains("implementer")
810 || lower.contains("read_write")
811 || lower.contains("allow_write")
812 || lower.contains("write_file")
813 || lower.contains("\"write\"")
814 || lower.contains("\"edit\"")
815 || lower.contains("\"file\"")
816 || lower.contains("apply_patch")
817 || script_tool_authority(script).writes
818 }
819
820 fn script_suggests_shell(script: &str) -> bool {
821 let lower = script.to_ascii_lowercase();
822 lower.contains("exec_shell")
823 || lower.contains("\"bash\"")
824 || lower.contains("bash(")
825 || ((lower.contains("allowedtools") || lower.contains("allowed_tools"))
826 && (lower.contains("shell") || lower.contains("bash")))
827 || script_tool_authority(script).shell
828 }
829
830 fn script_suggests_network(script: &str) -> bool {
831 let lower = script.to_ascii_lowercase();
832 lower.contains("allow_network")
833 || lower.contains("web_search")
834 || lower.contains("fetch_url")
835 || lower.contains("\"web\"")
836 || script_tool_authority(script).network
837 }
838
839 fn count_script_tasks(script: &str) -> usize {
840 script.matches("task(").count()
841 }
842
843 fn optional_u64(value: &Value, key: &str) -> Option<u64> {
844 value.get(key).and_then(Value::as_u64)
845 }
846
847 /// A budget is "high" only against a configured baseline; when
848 /// `[workflow].default_token_budget` is 0 (the default) nothing is high.
849 fn is_high_budget(token_budget: Option<u64>, config: &WorkflowConfigToml) -> bool {
850 config.default_token_budget > 0 && token_budget.is_some_and(|b| b > config.default_token_budget)
851 }
852
853 fn budget_label(token_budget: Option<u64>, high_budget: bool) -> String {
854 match token_budget {
855 Some(n) if high_budget => format!("{n} tokens (high)"),
856 Some(n) => format!("{n} tokens"),
857 None => "unbounded".to_string(),
858 }
859 }
860
861 fn yn(v: bool) -> &'static str {
862 if v { "yes" } else { "no" }
863 }
864
865 fn truncate(s: &str, max: usize) -> String {
866 let s = s.trim();
867 if s.chars().count() <= max {
868 s.to_string()
869 } else {
870 let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
871 out.push('…');
872 out
873 }
874 }
875
876 #[cfg(test)]
877 mod tests {
878 use super::*;
879 use serde_json::json;
880
881 fn config() -> WorkflowConfigToml {
882 WorkflowConfigToml::default()
883 }
884
885 #[test]
886 fn read_only_plan_is_not_elevated_and_auto_starts() {
887 let input = json!({
888 "action": "start",
889 "plan": {
890 "goal": "scout crates",
891 "risk": "read_only",
892 "token_budget": 50000,
893 "phases": [{
894 "id": "scout",
895 "children": [
896 { "id": "a", "prompt": "look left", "type": "explore" },
897 { "id": "b", "prompt": "look right", "type": "explore" }
898 ]
899 }]
900 }
901 });
902 let summary = analyze_workflow_plan_approval(&input);
903 assert!(!summary.elevated, "{summary:?}");
904 assert_eq!(summary.child_count, 2);
905 assert_eq!(summary.phase_count, 1);
906 assert!(!summary.writes);
907 assert_eq!(
908 workflow_approval_requirement_for(&input, &config()),
909 ApprovalRequirement::Auto
910 );
911 let fields = summary.card_fields();
912 assert_eq!(fields.len(), 6);
913 assert!(
914 fields
915 .iter()
916 .any(|(k, v)| *k == "Goal" && v.contains("scout"))
917 );
918 assert!(fields.iter().any(|(k, v)| *k == "Writes" && v == "no"));
919 assert!(fields.iter().any(|(k, v)| *k == "Shell" && v == "no"));
920 assert!(fields.iter().any(|(k, v)| *k == "Network" && v == "no"));
921 assert!(fields.iter().any(|(k, _)| *k == "Children"));
922 assert!(fields.iter().any(|(k, _)| *k == "Budget"));
923 let impacts = summary.approval_impacts();
924 assert!(impacts.iter().any(|i| i.contains("Goal: scout")));
925 assert!(impacts.iter().any(|i| i.contains("Writes: no")));
926 }
927
928 #[test]
929 fn write_plan_is_elevated_with_card_fields_and_requires_approval() {
930 let input = json!({
931 "action": "start",
932 "plan": {
933 "goal": "land the fix",
934 "risk": "writes",
935 "token_budget": 120000,
936 "children": [
937 {
938 "id": "builder",
939 "label": "impl",
940 "prompt": "patch it",
941 "type": "implementer",
942 "mode": "read_write"
943 }
944 ]
945 }
946 });
947 let summary = analyze_workflow_plan_approval(&input);
948 assert!(summary.elevated);
949 assert!(summary.writes);
950 assert!(summary.shell);
951 assert_eq!(
952 workflow_approval_requirement_for(&input, &config()),
953 ApprovalRequirement::Required
954 );
955 let fields = summary.card_fields();
956 assert!(fields.iter().any(|(k, v)| *k == "Writes" && v == "yes"));
957 assert!(fields.iter().any(|(k, v)| *k == "Shell" && v == "yes"));
958 assert!(
959 fields
960 .iter()
961 .any(|(k, v)| *k == "Budget" && v.contains("120000"))
962 );
963 let impacts = summary.approval_impacts();
964 assert!(impacts.iter().any(|i| i.contains("Writes: yes")));
965 assert!(impacts.iter().any(|i| i.contains("Approve to launch")));
966 let receipt = summary.to_receipt("approved", 99);
967 assert_eq!(receipt.decision, "approved");
968 assert_eq!(receipt.approved_at_ms, 99);
969 assert_eq!(receipt.goal, "land the fix");
970 assert!(receipt.elevated);
971 assert!(receipt.writes);
972 }
973
974 #[test]
975 fn capability_flags_match_tool_family_names_case_insensitively() {
976 let input = json!({
977 "action": "start",
978 "plan": {
979 "goal": "scout",
980 "children": [{
981 "prompt": "look",
982 "type": "explore",
983 "permissions": { "allowed_tools": ["Bash", "Web", "File"] }
984 }]
985 }
986 });
987 let summary = analyze_workflow_plan_approval(&input);
988 assert!(summary.shell, "{summary:?}");
989 assert!(summary.network, "{summary:?}");
990 assert!(summary.writes, "{summary:?}");
991
992 let script = r#"task({ allowedTools: ["Web", "File"] })"#;
993 assert!(script_suggests_network(script));
994 assert!(script_suggests_writes(script));
995 }
996
997 #[test]
998 fn capability_flags_cover_quote_styles_patterns_and_more_families() {
999 for script in [
1000 "task({ allowedTools: ['Web', 'File'] })",
1001 "task({ allowedTools: [`Web`, `File`] })",
1002 "task({ allowedTools: ['Web(*)', 'File(*)'] })",
1003 ] {
1004 assert!(script_suggests_network(script), "{script}");
1005 assert!(script_suggests_writes(script), "{script}");
1006 }
1007 assert!(script_suggests_shell("task({ allowedTools: ['Bash'] })"));
1008 // Prompt text that mentions a tool is not a tool name.
1009 let prose = "task({ prompt: 'read the file and summarise the web page' })";
1010 assert!(!script_suggests_writes(prose));
1011 assert!(!script_suggests_network(prose));
1012
1013 let flags = |tools: Value| {
1014 analyze_workflow_plan_approval(&json!({
1015 "action": "start",
1016 "plan": {
1017 "goal": "scout",
1018 "children": [{
1019 "prompt": "look",
1020 "type": "explore",
1021 "permissions": { "allowed_tools": tools }
1022 }]
1023 }
1024 }))
1025 };
1026 let summary = flags(json!([" Web(*) ", "File(src/**)"]));
1027 assert!(summary.network && summary.writes, "{summary:?}");
1028 let summary = flags(json!(["rlm"]));
1029 assert!(summary.shell && summary.network, "{summary:?}");
1030 for family in ["github", "Git", "computer", "browser"] {
1031 let summary = flags(json!([family]));
1032 assert!(summary.network && summary.writes, "{family}: {summary:?}");
1033 }
1034 for family in ["tasks", "automation", "Run"] {
1035 let summary = flags(json!([family]));
1036 assert!(summary.shell, "{family}: {summary:?}");
1037 }
1038 // Read-only Git tools stay read-only.
1039 let summary = flags(json!(["git_status", "git_diff"]));
1040 assert!(!summary.network && !summary.writes, "{summary:?}");
1041 }
1042
1043 #[test]
1044 fn canonical_worker_role_flags_shell_for_write_plan() {
1045 let input = json!({
1046 "action": "start",
1047 "plan": {
1048 "goal": "land the fix",
1049 "children": [{
1050 "prompt": "patch it",
1051 "type": "worker",
1052 "mode": "read_write"
1053 }]
1054 }
1055 });
1056
1057 let summary = analyze_workflow_plan_approval(&input);
1058 assert!(summary.writes);
1059 assert!(summary.shell);
1060 }
1061
1062 #[test]
1063 fn lowercase_bash_is_classified_as_shell_authority() {
1064 for script in [
1065 r#"{"allowed_tools":["bash"]}"#,
1066 r#"bash({"command":"pwd"})"#,
1067 ] {
1068 assert!(script_suggests_shell(script), "{script}");
1069 }
1070 }
1071
1072 #[test]
1073 fn read_only_implementer_does_not_request_write_or_shell_authority() {
1074 for child in [
1075 json!({
1076 "prompt": "review an implementation",
1077 "type": "implementer",
1078 "mode": "read_only"
1079 }),
1080 json!({
1081 "prompt": "review under the plan envelope",
1082 "type": "implementer"
1083 }),
1084 ] {
1085 let summary = analyze_workflow_plan_approval(&json!({
1086 "plan": {
1087 "goal": "read-only implementation review",
1088 "risk": "read_only",
1089 "children": [child]
1090 }
1091 }));
1092 assert!(!summary.writes, "{summary:?}");
1093 assert!(!summary.shell, "{summary:?}");
1094 assert!(!summary.elevated, "{summary:?}");
1095 }
1096
1097 let omitted_risk = analyze_workflow_plan_approval(&json!({
1098 "plan": {
1099 "goal": "default-safe implementation review",
1100 "children": [{ "prompt": "inspect", "type": "implementer" }]
1101 }
1102 }));
1103 assert!(!omitted_risk.writes, "{omitted_risk:?}");
1104 assert!(!omitted_risk.shell, "{omitted_risk:?}");
1105
1106 for risk in ["medium", "readwrite"] {
1107 let write_default = analyze_workflow_plan_approval(&json!({
1108 "plan": {
1109 "goal": "default writer",
1110 "risk": risk,
1111 "children": [{ "prompt": "patch" }]
1112 }
1113 }));
1114 assert!(write_default.writes, "{risk}: {write_default:?}");
1115 assert!(write_default.shell, "{risk}: {write_default:?}");
1116 }
1117 }
1118
1119 #[test]
1120 fn elevated_risk_flags_shell_and_network() {
1121 let summary = analyze_workflow_plan_approval(&json!({
1122 "plan": {
1123 "goal": "full authority",
1124 "risk": "elevated",
1125 "children": [{ "prompt": "go", "type": "implementer" }]
1126 }
1127 }));
1128 assert!(summary.elevated);
1129 assert!(summary.writes);
1130 assert!(summary.shell);
1131 assert!(summary.network);
1132 let fields = summary.card_fields();
1133 assert!(fields.iter().any(|(k, v)| *k == "Network" && v == "yes"));
1134 }
1135
1136 #[test]
1137 fn high_budget_elevates_read_only_plan() {
1138 let input = json!({
1139 "action": "start",
1140 "plan": {
1141 "goal": "huge scout",
1142 "risk": "read_only",
1143 "token_budget": 250_000,
1144 "children": [{ "prompt": "scan", "type": "explore" }]
1145 }
1146 });
1147 // With no configured baseline (the default) nothing is "high" — the
1148 // flag only exists relative to an operator-set cap (#6189).
1149 let uncapped = analyze_workflow_plan_approval(&input);
1150 assert!(!uncapped.high_budget, "{uncapped:?}");
1151 let config = WorkflowConfigToml {
1152 default_token_budget: 120_000,
1153 ..WorkflowConfigToml::default()
1154 };
1155 let summary = analyze_workflow_plan_approval_with_config(&input, &config);
1156 assert!(summary.high_budget, "{summary:?}");
1157 assert!(summary.elevated);
1158 assert!(summary.budget_label.contains("high"));
1159 assert_eq!(
1160 workflow_approval_requirement_for(&input, &config),
1161 ApprovalRequirement::Required
1162 );
1163 }
1164
1165 #[test]
1166 fn secrets_and_network_tools_elevate() {
1167 let summary = analyze_workflow_plan_approval(&json!({
1168 "plan": {
1169 "goal": "creds",
1170 "risk": "read_only",
1171 "children": [{
1172 "id": "s",
1173 "prompt": "read secrets",
1174 "type": "explore",
1175 "permissions": {
1176 "allow_network": true,
1177 "allowed_tools": ["read_secret", "fetch_url"]
1178 }
1179 }]
1180 }
1181 }));
1182 assert!(summary.elevated);
1183 assert!(summary.secrets);
1184 assert!(summary.network);
1185 }
1186
1187 #[test]
1188 fn status_is_auto_cancel_is_required() {
1189 assert_eq!(
1190 workflow_approval_requirement_for(&json!({"action": "status"}), &config()),
1191 ApprovalRequirement::Auto
1192 );
1193 assert_eq!(
1194 workflow_approval_requirement_for(
1195 &json!({"action": "cancel", "run_id": "x"}),
1196 &config()
1197 ),
1198 ApprovalRequirement::Required
1199 );
1200 }
1201
1202 #[test]
1203 fn require_approval_for_writes_true_blocks_write_start_read_only_stays_auto() {
1204 let mut cfg = config();
1205 cfg.require_approval_for_writes = true;
1206 cfg.auto_start_read_only = true;
1207 let write_plan = json!({
1208 "action": "start",
1209 "plan": {
1210 "goal": "land the fix",
1211 "risk": "writes",
1212 "children": [{
1213 "prompt": "patch it",
1214 "type": "implementer",
1215 "mode": "read_write"
1216 }]
1217 }
1218 });
1219 let read_only = json!({
1220 "action": "start",
1221 "plan": {
1222 "goal": "scout crates",
1223 "risk": "read_only",
1224 "children": [{ "prompt": "look", "type": "explore" }]
1225 }
1226 });
1227 assert_eq!(
1228 workflow_approval_requirement_for(&write_plan, &cfg),
1229 ApprovalRequirement::Required,
1230 "require_approval_for_writes = true must require the card for a write start"
1231 );
1232 assert_eq!(
1233 workflow_approval_requirement_for(&read_only, &cfg),
1234 ApprovalRequirement::Auto,
1235 "auto_start_read_only = true must still auto-start a read-only plan"
1236 );
1237 }
1238
1239 #[test]
1240 fn require_approval_for_writes_false_allows_elevated_auto() {
1241 let mut cfg = config();
1242 cfg.require_approval_for_writes = false;
1243 let input = json!({
1244 "action": "start",
1245 "plan": {
1246 "goal": "write freely",
1247 "risk": "writes",
1248 "children": [{ "prompt": "edit", "type": "implementer" }]
1249 }
1250 });
1251 assert_eq!(
1252 workflow_approval_requirement_for(&input, &cfg),
1253 ApprovalRequirement::Auto
1254 );
1255 }
1256
1257 #[test]
1258 fn verify_gates_leave_the_read_only_envelope() {
1259 let plan = json!({
1260 "goal": "scout crates",
1261 "risk": "read_only",
1262 "children": [{ "prompt": "look", "type": "explore" }]
1263 });
1264 let without = json!({ "action": "start", "plan": plan.clone() });
1265 let with_verify = json!({ "action": "start", "plan": plan, "verify": true });
1266 assert_eq!(
1267 workflow_approval_requirement_for(&without, &config()),
1268 ApprovalRequirement::Auto
1269 );
1270 let summary = analyze_workflow_plan_approval(&with_verify);
1271 assert!(summary.elevated && summary.shell, "{summary:?}");
1272 assert!(summary.reasons.iter().any(|r| r == "verify_gates"));
1273 assert_eq!(
1274 workflow_approval_requirement_for(&with_verify, &config()),
1275 ApprovalRequirement::Required,
1276 "completion gates run workspace code and need the approval card"
1277 );
1278 }
1279
1280 #[test]
1281 fn script_launch_requires_approval() {
1282 assert_eq!(
1283 workflow_approval_requirement_for(
1284 &json!({"action": "start", "script": "return 1;"}),
1285 &config()
1286 ),
1287 ApprovalRequirement::Required
1288 );
1289 }
1290
1291 #[test]
1292 fn card_fields_always_six_required_labels() {
1293 let summary = analyze_workflow_plan_approval(&json!({
1294 "plan": {
1295 "goal": "x",
1296 "risk": "read_only",
1297 "children": [{ "prompt": "y", "type": "explore" }]
1298 }
1299 }));
1300 let labels: Vec<_> = summary.card_fields().iter().map(|(k, _)| *k).collect();
1301 assert_eq!(
1302 labels,
1303 vec!["Goal", "Children", "Writes", "Shell", "Network", "Budget"]
1304 );
1305 }
1306 }
1307
1307 lines RUST