返回 CodeWhale
authority.rs
根目录 / crates / tui / src / core / authority.rs
1 //! Turn authority and mode/posture policy projections.
2 //!
3 //! Keep mode, approval, shell, sandbox, trust, and input provenance decisions
4 //! in one place so prompt metadata, tool catalogs, and runtime gates cannot
5 //! drift independently.
6
7 use std::ffi::OsStr;
8 use std::path::{Component, Path, PathBuf};
9
10 use crate::sandbox::SandboxPolicy;
11 use crate::tools::canonical_action::canonical_action_alias;
12 use crate::tools::spec::{ApprovalRequirement, normalize_path};
13 use crate::worker_profile::ShellPolicy;
14 use codewhale_config::AppMode;
15 use codewhale_execpolicy::ApprovalMode;
16 use serde_json::Value;
17
18 use super::ops::UserInputProvenance;
19
20 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
21 pub enum RunOrigin {
22 Interactive,
23 Headless,
24 Background,
25 }
26
27 impl RunOrigin {
28 #[must_use]
29 pub fn as_str(self) -> &'static str {
30 match self {
31 Self::Interactive => "interactive",
32 Self::Headless => "headless",
33 Self::Background => "background",
34 }
35 }
36 }
37
38 /// Durable Agent-era permission baseline that Plan/YOLO restore to (#3386).
39 ///
40 /// Mode cycling used to be tangled with permission policy: each mode mutated
41 /// `allow_shell`/`trust_mode`/`approval_mode` directly and ad-hoc snapshots
42 /// tried to put things back on exit. Instead, keep one canonical baseline: the
43 /// permission surface the user has chosen for Agent mode.
44 #[derive(Debug, Clone, Copy)]
45 pub(crate) struct ModeSessionPrefs {
46 pub(crate) agent_allow_shell: bool,
47 pub(crate) agent_trust_mode: bool,
48 pub(crate) agent_approval_mode: ApprovalMode,
49 }
50
51 /// The permission policy a given [`AppMode`] resolves to (#3386).
52 #[derive(Debug, Clone, Copy)]
53 pub(crate) struct EffectiveModePolicy {
54 #[cfg_attr(not(test), expect(dead_code))]
55 pub(crate) mode: AppMode,
56 pub(crate) allow_shell: bool,
57 pub(crate) trust_mode: bool,
58 pub(crate) approval_mode: ApprovalMode,
59 }
60
61 /// Resolve a mode's effective permission policy from the durable Agent baseline.
62 ///
63 /// This is the single source of truth for the mode/permission table:
64 /// - `Plan` -> read-only: no shell, no trust, `Suggest` approvals.
65 /// - `Agent` -> the user's durable baseline (`prefs`).
66 /// - `Operate` -> Agent baseline plus orchestration capabilities in the runtime.
67 ///
68 /// The legacy YOLO spelling resolves to Agent plus a `Bypass` approval
69 /// posture before it reaches this table; modes no longer carry permission.
70 #[must_use]
71 pub(crate) fn base_policy_for_mode(mode: AppMode, prefs: &ModeSessionPrefs) -> EffectiveModePolicy {
72 match mode {
73 AppMode::Plan => EffectiveModePolicy {
74 mode,
75 allow_shell: false,
76 trust_mode: false,
77 approval_mode: ApprovalMode::Suggest,
78 },
79 AppMode::Agent | AppMode::Operate => EffectiveModePolicy {
80 mode,
81 allow_shell: prefs.agent_allow_shell,
82 trust_mode: prefs.agent_trust_mode,
83 approval_mode: prefs.agent_approval_mode,
84 },
85 }
86 }
87
88 /// Why runtime policy narrowed the authority a turn was asked to run with.
89 ///
90 /// One variant per narrowing site. Adding a site means adding a variant, which
91 /// is the mechanism that makes "no silent effective mode change" enforceable
92 /// rather than aspirational (#3947).
93 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
94 pub(crate) enum PolicyNarrowingReason {
95 /// Input arrived from a provenance that cannot inherit standing
96 /// auto-approval authority (sub-agent handoffs, restored checkpoints).
97 NonAuthoritativeProvenance,
98 }
99
100 impl PolicyNarrowingReason {
101 /// Stable machine-readable identifier. Shared by the model-visible
102 /// metadata line and doctor output so the two cannot drift.
103 pub(crate) fn as_str(self) -> &'static str {
104 match self {
105 Self::NonAuthoritativeProvenance => "non_authoritative_provenance",
106 }
107 }
108 }
109
110 /// A structured record of one authority narrowing.
111 ///
112 /// Before this existed, narrowing produced only a free-text UI status line:
113 /// the model saw the narrowed posture but never learned it had been narrowed
114 /// or why, and doctor could not report it at all. Every consumer now renders
115 /// from this one value, so the UI status, the `<turn_meta>` line, and doctor
116 /// necessarily agree.
117 #[derive(Debug, Clone, PartialEq, Eq)]
118 pub(crate) struct PolicyNarrowingEvent {
119 reason: PolicyNarrowingReason,
120 /// Mode before narrowing, and after, as setting strings.
121 from_mode: &'static str,
122 to_mode: &'static str,
123 /// Permission posture before narrowing, and after.
124 from_approval: ApprovalMode,
125 to_approval: ApprovalMode,
126 /// Human-readable cause, e.g. the provenance that could not inherit.
127 detail: String,
128 }
129
130 impl PolicyNarrowingEvent {
131 pub(crate) fn reason(&self) -> PolicyNarrowingReason {
132 self.reason
133 }
134
135 /// The single user-facing sentence. The TUI status line renders exactly
136 /// this, and the model-visible metadata carries the same string.
137 pub(crate) fn message(&self) -> String {
138 match self.reason {
139 PolicyNarrowingReason::NonAuthoritativeProvenance => format!(
140 "Input provenance '{}' cannot inherit standing auto-approval authority; continuing with approvals required.",
141 self.detail
142 ),
143 }
144 }
145
146 /// Compact `from -> to` summary for doctor and debug surfaces.
147 pub(crate) fn transition(&self) -> String {
148 format!(
149 "{} ({}) -> {} ({})",
150 self.from_mode,
151 self.from_approval.permission_chip_label(),
152 self.to_mode,
153 self.to_approval.permission_chip_label(),
154 )
155 }
156 }
157
158 /// Effective authority for one engine turn after provenance narrowing.
159 #[derive(Debug, Clone)]
160 pub(crate) struct TurnAuthority {
161 pub(crate) mode: AppMode,
162 pub(crate) allow_shell: bool,
163 pub(crate) trust_mode: bool,
164 pub(crate) auto_approve: bool,
165 pub(crate) approval_mode: ApprovalMode,
166 pub(crate) dynamic_active_tools: Vec<&'static str>,
167 /// Structured record of any narrowing applied to this turn (#3947). The
168 /// UI status line, `<turn_meta>`, and doctor all render from here, so a
169 /// narrowing that reaches one surface reaches all of them.
170 pub(crate) narrowing: Option<PolicyNarrowingEvent>,
171 }
172
173 impl TurnAuthority {
174 /// The user-facing status sentence for this turn's narrowing, if any.
175 pub(crate) fn status(&self) -> Option<String> {
176 self.narrowing.as_ref().map(PolicyNarrowingEvent::message)
177 }
178
179 #[must_use]
180 pub(crate) fn from_effective_fields(
181 mode: AppMode,
182 allow_shell: bool,
183 trust_mode: bool,
184 auto_approve: bool,
185 approval_mode: ApprovalMode,
186 ) -> Self {
187 Self {
188 mode,
189 allow_shell,
190 trust_mode,
191 auto_approve,
192 approval_mode,
193 dynamic_active_tools: Vec::new(),
194 narrowing: None,
195 }
196 }
197
198 #[must_use]
199 pub(crate) fn approval_mode_for_session(&self) -> ApprovalMode {
200 agent_approval_mode_for_turn(self.auto_approve, self.approval_mode)
201 }
202
203 /// Authority for the per-tool approval gate, folded from the legacy
204 /// session `auto_approve` bit so [`resolve_tool_permission`] observes the
205 /// same effective posture the old boolean helpers encoded: a set bit is
206 /// the Full Access posture (Bypass), a cleared bit is an ordinary Ask
207 /// turn. The engine's `Never` denial deliberately stays at the UI layer,
208 /// so this constructor never produces a `Never` posture.
209 #[must_use]
210 pub(crate) fn for_tool_approval_decision(auto_approve: bool) -> Self {
211 Self::from_effective_fields(
212 AppMode::Agent,
213 true,
214 false,
215 auto_approve,
216 posture_from_auto_approve(auto_approve),
217 )
218 }
219
220 #[must_use]
221 pub(crate) fn shell_policy(&self) -> ShellPolicy {
222 shell_policy_for_mode(self.mode, self.allow_shell)
223 }
224
225 #[must_use]
226 pub(crate) fn sandbox_policy(
227 &self,
228 workspace: &Path,
229 configured_mode: Option<&str>,
230 network_access: SandboxNetworkAccess,
231 ) -> SandboxPolicy {
232 sandbox_policy_for_turn(
233 self.mode,
234 self.approval_mode_for_session(),
235 configured_mode,
236 workspace,
237 network_access,
238 )
239 }
240 }
241
242 /// The posture a legacy `auto_approve` bit stands for: a set bit is Full
243 /// Access, a cleared bit is Ask.
244 ///
245 /// Every surface that carries the bit without a session posture folds it here —
246 /// the per-tool approval gate, a tool context built with only the bit, a
247 /// scheduled automation — so none of them can disagree about what it means.
248 #[must_use]
249 pub(crate) fn posture_from_auto_approve(auto_approve: bool) -> ApprovalMode {
250 if auto_approve {
251 ApprovalMode::Bypass
252 } else {
253 ApprovalMode::Suggest
254 }
255 }
256
257 #[must_use]
258 pub(crate) fn effective_input_policy(
259 provenance: UserInputProvenance,
260 requested_mode: AppMode,
261 _content: &str,
262 allow_shell: bool,
263 trust_mode: bool,
264 auto_approve: bool,
265 approval_mode: ApprovalMode,
266 ) -> TurnAuthority {
267 let mode = requested_mode;
268 let mut trust_mode = trust_mode;
269 let mut auto_approve = auto_approve;
270 let mut approval_mode = approval_mode;
271 let mut narrowing = None;
272
273 if !provenance_can_inherit_standing_auto_authority(provenance) {
274 let from_mode = mode;
275 let from_approval = approval_mode;
276 let had_auto_authority =
277 trust_mode || auto_approve || matches!(approval_mode, ApprovalMode::Bypass);
278 trust_mode = false;
279 auto_approve = false;
280 if matches!(approval_mode, ApprovalMode::Auto | ApprovalMode::Bypass) {
281 approval_mode = ApprovalMode::Suggest;
282 }
283 if had_auto_authority {
284 // Record the transition, not just a sentence about it: the same
285 // value drives the UI status, `<turn_meta>`, and doctor (#3947).
286 narrowing = Some(PolicyNarrowingEvent {
287 reason: PolicyNarrowingReason::NonAuthoritativeProvenance,
288 from_mode: from_mode.as_setting(),
289 to_mode: mode.as_setting(),
290 from_approval,
291 to_approval: approval_mode,
292 detail: provenance.as_str().to_string(),
293 });
294 }
295 }
296
297 // The named permission posture is authoritative. Normalize legacy or
298 // host inputs that carry `Bypass` with a stale false auto-approve bit so
299 // every engine surface observes the same Full Access contract.
300 if approval_mode == ApprovalMode::Bypass {
301 auto_approve = true;
302 }
303
304 TurnAuthority {
305 mode,
306 allow_shell,
307 trust_mode,
308 auto_approve,
309 approval_mode,
310 dynamic_active_tools: Vec::new(),
311 narrowing,
312 }
313 }
314
315 #[must_use]
316 pub(crate) fn provenance_can_inherit_standing_auto_authority(
317 provenance: UserInputProvenance,
318 ) -> bool {
319 matches!(
320 provenance,
321 UserInputProvenance::ExternalUser
322 | UserInputProvenance::Runtime
323 | UserInputProvenance::SubAgentHandoff
324 )
325 }
326
327 #[must_use]
328 pub(crate) fn agent_approval_mode_for_turn(
329 auto_approve: bool,
330 approval_mode: ApprovalMode,
331 ) -> ApprovalMode {
332 if auto_approve {
333 ApprovalMode::Bypass
334 } else {
335 approval_mode
336 }
337 }
338
339 /// Resolve the filesystem boundary for one turn.
340 ///
341 /// Permission posture and filesystem scope are separate controls, but the
342 /// named Full Access posture must have a truthful default: outside Plan it
343 /// disables Codewhale's own sandbox, matching the product meaning of the
344 /// name. An explicit effective sandbox setting may still *tighten* that
345 /// default. It can never loosen Plan, Ask, or Auto-Review.
346 #[must_use]
347 pub(crate) fn sandbox_policy_for_turn(
348 mode: AppMode,
349 approval_mode: ApprovalMode,
350 configured_mode: Option<&str>,
351 workspace: &Path,
352 network_access: SandboxNetworkAccess,
353 ) -> SandboxPolicy {
354 let default = if mode == AppMode::Plan {
355 SandboxPolicy::ReadOnly
356 } else if approval_mode == ApprovalMode::Bypass {
357 SandboxPolicy::DangerFullAccess
358 } else {
359 workspace_write_policy(workspace, network_access)
360 };
361
362 // The effective Config has already applied managed/project precedence.
363 // Only stricter scopes clamp the posture-derived default: a configured
364 // danger-full-access value must not silently loosen Ask or Auto-Review.
365 match (default, configured_mode) {
366 (SandboxPolicy::ReadOnly, _) | (_, Some("read-only")) => SandboxPolicy::ReadOnly,
367 (SandboxPolicy::DangerFullAccess, Some("workspace-write")) => {
368 workspace_write_policy(workspace, network_access)
369 }
370 (SandboxPolicy::DangerFullAccess, Some("external-sandbox")) => {
371 SandboxPolicy::ExternalSandbox {
372 network_access: network_access.is_allowed(),
373 }
374 }
375 (policy, _) => policy,
376 }
377 }
378
379 /// Whether a sandboxed turn may open outbound connections.
380 ///
381 /// Typed rather than a bare `bool` so the two call-site meanings — "the user
382 /// asked for network" and "some caller passed true" — cannot be transposed
383 /// silently, and so the default is spelled at the type instead of at each of
384 /// the seven resolver call sites.
385 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
386 pub(crate) enum SandboxNetworkAccess {
387 /// No outbound network inside the sandbox. Editing the workspace does not
388 /// imply reaching the internet.
389 #[default]
390 Restricted,
391 /// Outbound network explicitly granted by config, policy, or an approved
392 /// elevation.
393 Allowed,
394 }
395
396 impl SandboxNetworkAccess {
397 #[must_use]
398 pub(crate) fn from_config(configured: Option<bool>) -> Self {
399 if configured.unwrap_or(false) {
400 Self::Allowed
401 } else {
402 Self::Restricted
403 }
404 }
405
406 #[must_use]
407 pub(crate) fn is_allowed(self) -> bool {
408 matches!(self, Self::Allowed)
409 }
410 }
411
412 fn workspace_write_policy(workspace: &Path, network_access: SandboxNetworkAccess) -> SandboxPolicy {
413 SandboxPolicy::WorkspaceWrite {
414 writable_roots: vec![workspace.to_path_buf()],
415 network_access: network_access.is_allowed(),
416 exclude_tmpdir: false,
417 exclude_slash_tmp: false,
418 }
419 }
420
421 /// Resolve the effective shell policy for a turn from legacy shell opt-in plus mode.
422 #[must_use]
423 pub(crate) fn shell_policy_for_mode(mode: AppMode, allow_shell: bool) -> ShellPolicy {
424 if !allow_shell {
425 return ShellPolicy::None;
426 }
427 match mode {
428 AppMode::Plan => ShellPolicy::None,
429 AppMode::Agent | AppMode::Operate => ShellPolicy::Full,
430 }
431 }
432
433 /// Per-tool permission decision from the unified resolver (#4412).
434 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
435 pub(crate) enum ToolPermission {
436 /// Tool executes without any approval prompt.
437 Allow,
438 /// Tool requires user approval before execution.
439 Prompt,
440 /// Tool is denied without a prompt (approval_mode=Never).
441 Deny,
442 }
443
444 /// Unified per-tool permission resolver (#4412).
445 ///
446 /// Consolidates the approval decision that was previously scattered across
447 /// `registered_tool_approval_required` (turn_loop), `app_auto_approve_enabled`
448 /// (ui.rs), and the `Never` short-circuit. One call site, one answer.
449 ///
450 /// The truth table mirrors the legacy helpers exactly:
451 /// - `Auto` tools always run — even under `Never`, which stays read-only
452 /// rather than dead.
453 /// - `Never` denies any tool that would otherwise prompt, but only when the
454 /// authority is not full-access shaped: a Bypass-shaped authority carrying
455 /// a stale `Never` enum still auto-approves, matching the legacy UI order
456 /// in which the full-access shortcut ran before the `Never` check.
457 /// - `Suggest` and `Required` are both bypassable by auto-approve authority
458 /// unless the tool is on the typed non-bypassable hold list
459 /// (`is_non_bypassable`), which always prompts. A generic `Required` tool
460 /// remains auto-approved in Full Access (#3866).
461 #[must_use]
462 pub(crate) fn resolve_tool_permission(
463 authority: &TurnAuthority,
464 requirement: ApprovalRequirement,
465 is_non_bypassable: bool,
466 ) -> ToolPermission {
467 if authority.approval_mode == ApprovalMode::Never
468 && requirement != ApprovalRequirement::Auto
469 && !authority.auto_approve
470 {
471 return ToolPermission::Deny;
472 }
473 match requirement {
474 ApprovalRequirement::Auto => ToolPermission::Allow,
475 ApprovalRequirement::Suggest | ApprovalRequirement::Required => {
476 if is_non_bypassable {
477 // Full Access already grants everything these calls can do —
478 // shell included — so a hold that cannot open its own
479 // approval modal auto-approves instead of stranding the call.
480 // #3866 blocked here through v0.9.6; reversed 2026-08-10.
481 return if authority.auto_approve {
482 ToolPermission::Allow
483 } else {
484 ToolPermission::Prompt
485 };
486 }
487 if authority.auto_approve || authority.approval_mode == ApprovalMode::Bypass {
488 ToolPermission::Allow
489 } else {
490 ToolPermission::Prompt
491 }
492 }
493 }
494 }
495
496 /// Whether the session posture is the one the in-workspace write carve-out
497 /// (#5185) relaxes: the default Ask posture (`Suggest` approvals, no
498 /// auto-approve) in an Agent-family mode.
499 ///
500 /// Every other posture keeps its exact prior meaning: Full Access already
501 /// runs these calls, `Never` still denies them, Auto-Review still fails
502 /// unresolved holds closed, and Plan is read-only by mode.
503 #[must_use]
504 pub(crate) fn write_carve_out_posture(
505 mode: AppMode,
506 approval_mode: ApprovalMode,
507 auto_approve: bool,
508 ) -> bool {
509 !auto_approve
510 && matches!(mode, AppMode::Agent | AppMode::Operate)
511 && approval_mode == ApprovalMode::Suggest
512 }
513
514 /// Whether every target path of a file-write call qualifies for the
515 /// in-workspace write carve-out (#5185): the workspace is a git work tree,
516 /// each path resolves inside it, and none touches `.git` internals, runtime
517 /// state, or a sensitive file.
518 ///
519 /// The git work-tree marker is deliberate (the same shape as kimi-code's
520 /// `git-cwd-write-approve` policy): the carve-out exists because
521 /// version-controlled edits stay reviewable and recoverable, so a workspace
522 /// without git keeps the modal.
523 #[must_use]
524 pub(crate) fn paths_within_workspace_write_carve_out(workspace: &Path, paths: &[String]) -> bool {
525 if paths.is_empty() {
526 return false;
527 }
528 // `.git` may be a directory (normal checkout) or a file (worktree or
529 // submodule); either marks a git work tree.
530 if workspace.join(".git").symlink_metadata().is_err() {
531 return false;
532 }
533 let Ok(workspace_canonical) = workspace.canonicalize() else {
534 return false;
535 };
536 paths
537 .iter()
538 .all(|raw| carve_out_target_allowed(workspace, &workspace_canonical, raw))
539 }
540
541 fn carve_out_target_allowed(workspace: &Path, workspace_canonical: &Path, raw: &str) -> bool {
542 let raw = raw.trim();
543 if raw.is_empty() {
544 return false;
545 }
546 let raw_path = Path::new(raw);
547 let candidate = if raw_path.is_absolute() {
548 raw_path.to_path_buf()
549 } else {
550 workspace.join(raw_path)
551 };
552 // Lexical containment first: `..` escapes and absolute out-of-tree paths
553 // fail here without touching the filesystem.
554 let lexical = normalize_path(&candidate);
555 let workspace_lexical = normalize_path(workspace);
556 let workspace_canonical_lexical = normalize_path(workspace_canonical);
557 let Ok(relative) = lexical
558 .strip_prefix(&workspace_lexical)
559 .or_else(|_| lexical.strip_prefix(&workspace_canonical_lexical))
560 else {
561 return false;
562 };
563 if !carve_out_relative_path_allowed(relative) {
564 return false;
565 }
566 // Then symlink reality: resolve the deepest existing ancestor and
567 // require the real path to stay inside the real workspace and off the
568 // same exclusions (a symlink hop into `.git` or out of the tree fails).
569 let Some(resolved) = resolve_deepest_existing(&candidate) else {
570 return false;
571 };
572 let Ok(resolved_relative) = resolved.strip_prefix(workspace_canonical) else {
573 return false;
574 };
575 carve_out_relative_path_allowed(resolved_relative)
576 }
577
578 /// Canonicalize the deepest existing ancestor of `candidate` and re-append
579 /// the not-yet-existing tail, so write targets that do not exist yet still
580 /// get a real-path check.
581 fn resolve_deepest_existing(candidate: &Path) -> Option<PathBuf> {
582 let mut ancestor = candidate;
583 let mut suffix: Vec<&OsStr> = Vec::new();
584 loop {
585 if let Ok(canonical) = ancestor.canonicalize() {
586 let mut resolved = canonical;
587 for part in suffix.iter().rev() {
588 resolved.push(part);
589 }
590 return Some(resolved);
591 }
592 suffix.push(ancestor.file_name()?);
593 ancestor = ancestor.parent()?;
594 }
595 }
596
597 fn carve_out_relative_path_allowed(relative: &Path) -> bool {
598 relative.components().all(|component| {
599 let Component::Normal(part) = component else {
600 return true;
601 };
602 !is_carve_out_excluded_name(&part.to_string_lossy().to_ascii_lowercase())
603 })
604 }
605
606 /// Names the carve-out never auto-allows, matched per path component:
607 /// `.git` internals, runtime/project state, credential-bearing directories
608 /// and files, and key material.
609 fn is_carve_out_excluded_name(name: &str) -> bool {
610 // The shared `.git` rule, so a Windows alias (`.git.`, `GIT~1`) is
611 // excluded here exactly as the workspace file routes exclude it.
612 if crate::snapshot::is_git_metadata_name(OsStr::new(name)) {
613 return true;
614 }
615 // Runtime/project state and credential-bearing directories. `.codewhale`
616 // holds session state plus MCP/hook configuration — editing it changes
617 // what runs, so it keeps the modal.
618 if matches!(
619 name,
620 ".codewhale" | ".ssh" | ".aws" | ".gnupg" | ".kube" | ".docker"
621 ) {
622 return true;
623 }
624 // Environment files and well-known credential stores.
625 if name.starts_with(".env")
626 || name == ".netrc"
627 || name == ".npmrc"
628 || name == ".pypirc"
629 || name == ".git-credentials"
630 || name == "credentials"
631 || name.starts_with("credentials.")
632 {
633 return true;
634 }
635 // SSH private (and public) key material.
636 if name.starts_with("id_rsa")
637 || name.starts_with("id_dsa")
638 || name.starts_with("id_ecdsa")
639 || name.starts_with("id_ed25519")
640 {
641 return true;
642 }
643 // Key/certificate containers by extension.
644 matches!(
645 Path::new(name).extension().and_then(|ext| ext.to_str()),
646 Some("pem" | "key" | "p12" | "pfx" | "jks" | "keystore")
647 )
648 }
649
650 /// Disposition for an approval request that reached the UI (#4412).
651 ///
652 /// The engine emits `ApprovalRequired` whenever its resolver answer was
653 /// `Prompt`; the UI then disposes of that request — honoring session caches
654 /// and posture races — through this single decision.
655 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
656 pub(crate) enum ApprovalRequestDisposition {
657 /// Session grant or full-access posture: approve without a modal.
658 AutoApprove,
659 /// The user already denied this approval key this session (#360).
660 AutoDenySessionDenied,
661 /// A forced (non-bypassable) policy hold arrived under a full-access
662 /// posture that opens no modal: fail closed.
663 AutoDenyFullAccessPolicyHold,
664 /// Auto-Review is autonomous: unresolved holds fail closed instead of
665 /// opening a user-approval modal.
666 AutoDenyAutoReview,
667 /// approval_mode=Never: deny without a modal.
668 AutoDenyNeverPosture,
669 /// Open the approval modal.
670 Prompt,
671 }
672
673 /// Resolve how the UI disposes of one incoming approval request.
674 ///
675 /// `session_approved` / `session_denied` are the caller's lookups into the
676 /// session approval caches (grouping key or tool name / exact approval key).
677 /// The branch order: session denial, then the Auto-Review hold, then the
678 /// full-access policy-hold denial, then the `Never` denial — the live posture
679 /// wins over any remembered grant (approvals J) — then auto-approval (full
680 /// access or a session grant), and only finally a modal. Extension-origin
681 /// forced calls reach that modal even under Full Access; no remembered grant
682 /// can satisfy them. `extension_origin` comes from Rust-minted approval keys,
683 /// never an extension label or a claim supplied by the host.
684 #[must_use]
685 pub(crate) fn resolve_approval_request_disposition(
686 authority: &TurnAuthority,
687 session_approved: bool,
688 session_denied: bool,
689 approval_force_prompt: bool,
690 extension_origin: bool,
691 ) -> ApprovalRequestDisposition {
692 if session_denied {
693 return ApprovalRequestDisposition::AutoDenySessionDenied;
694 }
695 if authority.approval_mode_for_session() == ApprovalMode::Auto {
696 return ApprovalRequestDisposition::AutoDenyAutoReview;
697 }
698 // The request exists, so the engine already resolved Prompt for the tool
699 // itself. What remains is the posture question: how does this authority
700 // treat an ordinary promptable tool?
701 let posture = resolve_tool_permission(authority, ApprovalRequirement::Suggest, false);
702 if approval_force_prompt && !extension_origin && posture == ToolPermission::Allow {
703 return ApprovalRequestDisposition::AutoDenyFullAccessPolicyHold;
704 }
705 // The live posture wins over any remembered grant: a conversation grant
706 // given under Ask never outlives a later switch to Never (approvals J).
707 if posture == ToolPermission::Deny {
708 return ApprovalRequestDisposition::AutoDenyNeverPosture;
709 }
710 if !approval_force_prompt && (posture == ToolPermission::Allow || session_approved) {
711 return ApprovalRequestDisposition::AutoApprove;
712 }
713 ApprovalRequestDisposition::Prompt
714 }
715
716 /// Categorizes tools by cost/risk level.
717 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
718 pub enum ToolCategory {
719 /// Free, read-only operations (`list_dir`, `read_file`, todo_*)
720 Safe,
721 /// File modifications (`write_file`, `edit_file`)
722 FileWrite,
723 /// Shell execution (`exec_shell`)
724 Shell,
725 /// Network-oriented built-in tools
726 Network,
727 /// Read-only MCP discovery and resource access
728 McpRead,
729 /// MCP actions that may change remote state
730 McpAction,
731 /// Sub-agent lifecycle (`agent` start/status/peek/cancel); the child's
732 /// own tool gates govern what it may actually do.
733 Agent,
734 /// Unknown or unclassified tool surface
735 Unknown,
736 }
737
738 /// Get the category for a tool by name.
739 pub fn get_tool_category(name: &str) -> ToolCategory {
740 if name == "agent" || name == "workflow" {
741 // Workflow is multi-agent orchestration; reuse Agent stakes/routing
742 // and specialize the impact card via build_impact_summary (#4126).
743 ToolCategory::Agent
744 } else if matches!(
745 name,
746 "write" | "edit" | "write_file" | "edit_file" | "apply_patch"
747 ) {
748 ToolCategory::FileWrite
749 } else if matches!(
750 name,
751 "web_run" | "web_search" | "fetch_url" | "wait_for_dev_server" | "registry_sync"
752 ) {
753 ToolCategory::Network
754 } else if matches!(
755 name,
756 "bash"
757 | "Bash"
758 | "exec_shell"
759 | "task_shell_start"
760 | "task_shell_wait"
761 | "exec_shell_wait"
762 | "exec_shell_interact"
763 | "exec_shell_cancel"
764 | "exec_wait"
765 | "exec_interact"
766 ) {
767 ToolCategory::Shell
768 } else if name.starts_with("list_mcp_")
769 || name.starts_with("read_mcp_")
770 || name.starts_with("get_mcp_")
771 {
772 ToolCategory::McpRead
773 } else if name.starts_with("mcp_") {
774 ToolCategory::McpAction
775 } else if matches!(
776 name,
777 "read"
778 | "read_file"
779 | "list_dir"
780 | "work_update"
781 | "todo_write"
782 | "todo_read"
783 | "checklist_write"
784 | "note"
785 | "update_plan"
786 | "search"
787 | "file_search"
788 | "grep_files"
789 | "git_status"
790 | "git_diff"
791 | "git_log"
792 | "git_show"
793 | "git_blame"
794 | "git_commit_plan"
795 | "project"
796 | "diagnostics"
797 ) || name.starts_with("read_")
798 || name.starts_with("list_")
799 || name.starts_with("get_")
800 {
801 ToolCategory::Safe
802 } else if matches!(name, "start_mcp_server" | "start_registry_mcp_server") {
803 // Starting an MCP server spawns child processes or opens network
804 // connections — classify as McpAction to trigger appropriate
805 // approval prompts.
806 ToolCategory::McpAction
807 } else {
808 ToolCategory::Unknown
809 }
810 }
811
812 /// Categorize a concrete call after resolving an action-based canonical tool.
813 #[must_use]
814 pub fn get_tool_category_for_call(name: &str, params: &Value) -> ToolCategory {
815 get_tool_category(canonical_action_alias(name, params))
816 }
817
818 #[cfg(test)]
819 mod tests {
820 use super::*;
821
822 fn authority(mode: AppMode, auto_approve: bool, approval_mode: ApprovalMode) -> TurnAuthority {
823 TurnAuthority::from_effective_fields(mode, true, false, auto_approve, approval_mode)
824 }
825
826 #[test]
827 fn write_carve_out_posture_is_exactly_the_default_ask_posture() {
828 assert!(write_carve_out_posture(
829 AppMode::Agent,
830 ApprovalMode::Suggest,
831 false
832 ));
833 assert!(write_carve_out_posture(
834 AppMode::Operate,
835 ApprovalMode::Suggest,
836 false
837 ));
838 // Full Access already runs these calls; the carve-out must not be
839 // what allows them.
840 assert!(!write_carve_out_posture(
841 AppMode::Agent,
842 ApprovalMode::Bypass,
843 true
844 ));
845 // Never still denies; Auto-Review still fails unresolved holds closed;
846 // Plan is read-only by mode.
847 assert!(!write_carve_out_posture(
848 AppMode::Agent,
849 ApprovalMode::Never,
850 false
851 ));
852 assert!(!write_carve_out_posture(
853 AppMode::Agent,
854 ApprovalMode::Auto,
855 false
856 ));
857 assert!(!write_carve_out_posture(
858 AppMode::Plan,
859 ApprovalMode::Suggest,
860 false
861 ));
862 }
863
864 fn carve_out_workspace() -> tempfile::TempDir {
865 let tmp = tempfile::tempdir().expect("tempdir");
866 std::fs::create_dir(tmp.path().join(".git")).expect("git marker");
867 std::fs::create_dir_all(tmp.path().join("src")).expect("src dir");
868 std::fs::write(tmp.path().join("src/main.rs"), "fn main() {}\n").expect("source file");
869 tmp
870 }
871
872 #[test]
873 fn carve_out_allows_in_workspace_write_targets() {
874 let tmp = carve_out_workspace();
875 let workspace = tmp.path();
876 for paths in [
877 vec!["src/main.rs".to_string()],
878 vec!["src/new_file.rs".to_string()],
879 vec!["deeply/nested/not-yet-created.rs".to_string()],
880 vec!["./src/main.rs".to_string()],
881 vec![workspace.join("src/main.rs").to_string_lossy().into_owned()],
882 vec!["src/main.rs".to_string(), "src/other.rs".to_string()],
883 ] {
884 assert!(
885 paths_within_workspace_write_carve_out(workspace, &paths),
886 "{paths:?} should qualify"
887 );
888 }
889 }
890
891 #[test]
892 fn carve_out_rejects_out_of_tree_sensitive_and_git_paths() {
893 let tmp = carve_out_workspace();
894 let workspace = tmp.path();
895 for paths in [
896 vec!["../outside.rs".to_string()],
897 vec!["src/../../outside.rs".to_string()],
898 vec!["/etc/passwd".to_string()],
899 vec![".git/config".to_string()],
900 vec!["nested/.git/hooks/pre-commit".to_string()],
901 vec![".env".to_string()],
902 vec!["config/.env.production".to_string()],
903 vec![".ssh/config".to_string()],
904 vec!["deploy/id_rsa".to_string()],
905 vec!["certs/server.pem".to_string()],
906 vec![".codewhale/mcp.json".to_string()],
907 vec!["aws/credentials".to_string()],
908 // One bad target poisons the whole call.
909 vec!["src/main.rs".to_string(), ".env".to_string()],
910 ] {
911 assert!(
912 !paths_within_workspace_write_carve_out(workspace, &paths),
913 "{paths:?} must keep the modal"
914 );
915 }
916 }
917
918 #[test]
919 fn carve_out_excludes_git_metadata_by_the_shared_rule() {
920 let tmp = carve_out_workspace();
921 let workspace = tmp.path();
922 let mut refused = vec![".GIT/config", "nested/.Git/hooks/pre-commit"];
923 if cfg!(windows) {
924 refused.extend([".git./config", ".git /config", "GIT~1/config"]);
925 }
926 for path in refused {
927 assert!(
928 !paths_within_workspace_write_carve_out(workspace, &[path.to_string()]),
929 "{path} must keep the modal"
930 );
931 }
932 assert!(paths_within_workspace_write_carve_out(
933 workspace,
934 &[".github/workflows/ci.yml".to_string()]
935 ));
936 }
937
938 #[test]
939 fn carve_out_requires_a_git_work_tree() {
940 let tmp = tempfile::tempdir().expect("tempdir");
941 assert!(!paths_within_workspace_write_carve_out(
942 tmp.path(),
943 &["src/main.rs".to_string()]
944 ));
945 }
946
947 #[test]
948 fn carve_out_rejects_empty_target_list() {
949 let tmp = carve_out_workspace();
950 assert!(!paths_within_workspace_write_carve_out(tmp.path(), &[]));
951 }
952
953 #[cfg(unix)]
954 #[test]
955 fn carve_out_rejects_symlink_escapes() {
956 let tmp = carve_out_workspace();
957 let outside = tempfile::tempdir().expect("outside tempdir");
958 std::os::unix::fs::symlink(outside.path(), tmp.path().join("link")).expect("symlink");
959 assert!(!paths_within_workspace_write_carve_out(
960 tmp.path(),
961 &["link/evil.rs".to_string()]
962 ));
963 // A symlink that stays inside the workspace is fine.
964 std::os::unix::fs::symlink(tmp.path().join("src"), tmp.path().join("src-link"))
965 .expect("inner symlink");
966 assert!(paths_within_workspace_write_carve_out(
967 tmp.path(),
968 &["src-link/main.rs".to_string()]
969 ));
970 }
971
972 #[test]
973 fn full_access_is_unsandboxed_unless_effective_config_is_stricter() {
974 let workspace = Path::new("/work");
975 let full_access = authority(AppMode::Agent, true, ApprovalMode::Bypass);
976
977 assert_eq!(
978 full_access.sandbox_policy(workspace, None, SandboxNetworkAccess::Restricted),
979 SandboxPolicy::DangerFullAccess
980 );
981 // Clamping full-access down to workspace-write must land on the same
982 // restricted posture an ordinary Agent turn gets, not on a wider one.
983 assert!(matches!(
984 full_access.sandbox_policy(
985 workspace,
986 Some("workspace-write"),
987 SandboxNetworkAccess::Restricted
988 ),
989 SandboxPolicy::WorkspaceWrite { writable_roots, network_access, .. }
990 if writable_roots == vec![workspace.to_path_buf()] && !network_access
991 ));
992 assert_eq!(
993 full_access.sandbox_policy(
994 workspace,
995 Some("read-only"),
996 SandboxNetworkAccess::Restricted
997 ),
998 SandboxPolicy::ReadOnly
999 );
1000 // The external sandbox no longer claims network unconditionally; it
1001 // reports what was actually granted.
1002 assert!(matches!(
1003 full_access.sandbox_policy(
1004 workspace,
1005 Some("external-sandbox"),
1006 SandboxNetworkAccess::Restricted
1007 ),
1008 SandboxPolicy::ExternalSandbox {
1009 network_access: false
1010 }
1011 ));
1012 assert!(matches!(
1013 full_access.sandbox_policy(
1014 workspace,
1015 Some("external-sandbox"),
1016 SandboxNetworkAccess::Allowed
1017 ),
1018 SandboxPolicy::ExternalSandbox {
1019 network_access: true
1020 }
1021 ));
1022 }
1023
1024 #[test]
1025 fn workspace_write_never_grants_network_without_an_explicit_opt_in() {
1026 let workspace = Path::new("/work");
1027 // Every non-Yolo posture, with and without a configured sandbox mode.
1028 for approval_mode in [
1029 ApprovalMode::Suggest,
1030 ApprovalMode::Auto,
1031 ApprovalMode::Never,
1032 ] {
1033 for configured in [None, Some("workspace-write"), Some("danger-full-access")] {
1034 let auth = authority(AppMode::Agent, false, approval_mode);
1035 let policy =
1036 auth.sandbox_policy(workspace, configured, SandboxNetworkAccess::Restricted);
1037 assert!(
1038 !policy.has_network_access(),
1039 "{approval_mode:?}/{configured:?} leaked network: {policy:?}"
1040 );
1041 }
1042 }
1043
1044 // The Bypass posture is deliberately unsandboxed and keeps its
1045 // semantics: DangerFullAccess reports network regardless of this key,
1046 // because it applies no sandbox at all.
1047 let bypass = authority(AppMode::Agent, true, ApprovalMode::Bypass);
1048 let policy = bypass.sandbox_policy(workspace, None, SandboxNetworkAccess::Restricted);
1049 assert_eq!(policy, SandboxPolicy::DangerFullAccess);
1050 assert!(policy.has_network_access());
1051
1052 // Plan is read-only and denies network under either setting.
1053 let plan = authority(AppMode::Plan, false, ApprovalMode::Suggest);
1054 for access in [
1055 SandboxNetworkAccess::Restricted,
1056 SandboxNetworkAccess::Allowed,
1057 ] {
1058 assert!(
1059 !plan
1060 .sandbox_policy(workspace, None, access)
1061 .has_network_access()
1062 );
1063 }
1064 }
1065
1066 #[test]
1067 fn sandbox_network_access_defaults_to_restricted() {
1068 assert_eq!(
1069 SandboxNetworkAccess::default(),
1070 SandboxNetworkAccess::Restricted
1071 );
1072 assert_eq!(
1073 SandboxNetworkAccess::from_config(None),
1074 SandboxNetworkAccess::Restricted
1075 );
1076 assert_eq!(
1077 SandboxNetworkAccess::from_config(Some(false)),
1078 SandboxNetworkAccess::Restricted
1079 );
1080 assert_eq!(
1081 SandboxNetworkAccess::from_config(Some(true)),
1082 SandboxNetworkAccess::Allowed
1083 );
1084 }
1085
1086 #[test]
1087 fn plan_ask_and_auto_review_cannot_be_loosened_by_sandbox_config() {
1088 let workspace = Path::new("/work");
1089 for approval_mode in [ApprovalMode::Suggest, ApprovalMode::Auto] {
1090 let authority = authority(AppMode::Agent, false, approval_mode);
1091 assert!(matches!(
1092 authority.sandbox_policy(
1093 workspace,
1094 Some("danger-full-access"),
1095 SandboxNetworkAccess::Restricted
1096 ),
1097 SandboxPolicy::WorkspaceWrite { .. }
1098 ));
1099 }
1100
1101 let plan = authority(AppMode::Plan, true, ApprovalMode::Bypass);
1102 assert_eq!(
1103 plan.sandbox_policy(
1104 workspace,
1105 Some("danger-full-access"),
1106 SandboxNetworkAccess::Restricted
1107 ),
1108 SandboxPolicy::ReadOnly
1109 );
1110 }
1111
1112 #[test]
1113 fn outbound_web_payloads_require_a_session_decision_even_for_allowed_hosts() {
1114 use crate::tools::{
1115 fetch_url::FetchUrlTool, spec::ToolSpec, web_run::WebRunTool,
1116 web_search::WebSearchTool, web_tool::WebTool,
1117 };
1118 let request = serde_json::json!({"action": "fetch", "url": "https://example.com/collect?data=synthetic-secret"});
1119 for requirement in [
1120 FetchUrlTool.approval_requirement_for(&request),
1121 WebTool::new("Web").approval_requirement_for(&request),
1122 WebSearchTool.approval_requirement(),
1123 WebRunTool.approval_requirement(),
1124 ] {
1125 for approval in [ApprovalMode::Suggest, ApprovalMode::Auto] {
1126 let ask = authority(AppMode::Agent, false, approval);
1127 assert_eq!(
1128 resolve_tool_permission(&ask, requirement, false),
1129 ToolPermission::Prompt
1130 );
1131 }
1132 let never = authority(AppMode::Agent, false, ApprovalMode::Never);
1133 assert_eq!(
1134 resolve_tool_permission(&never, requirement, false),
1135 ToolPermission::Deny
1136 );
1137 let granted = authority(AppMode::Agent, true, ApprovalMode::Bypass);
1138 assert_eq!(
1139 resolve_tool_permission(&granted, requirement, false),
1140 ToolPermission::Allow
1141 );
1142 }
1143 let local_read = crate::tools::file::ReadFileTool.approval_requirement();
1144 assert_eq!(
1145 resolve_tool_permission(
1146 &authority(AppMode::Agent, false, ApprovalMode::Suggest),
1147 local_read,
1148 false
1149 ),
1150 ToolPermission::Allow
1151 );
1152 }
1153
1154 #[test]
1155 fn auto_requirement_always_allows() {
1156 for (mode, auto_approve, approval_mode) in [
1157 (AppMode::Agent, false, ApprovalMode::Suggest),
1158 (AppMode::Agent, false, ApprovalMode::Auto),
1159 (AppMode::Agent, false, ApprovalMode::Never),
1160 (AppMode::Agent, true, ApprovalMode::Bypass),
1161 (AppMode::Plan, false, ApprovalMode::Suggest),
1162 ] {
1163 let auth = authority(mode, auto_approve, approval_mode);
1164 for non_bypassable in [false, true] {
1165 assert_eq!(
1166 resolve_tool_permission(&auth, ApprovalRequirement::Auto, non_bypassable),
1167 ToolPermission::Allow,
1168 "{mode:?}/{auto_approve}/{approval_mode:?}/nb={non_bypassable}"
1169 );
1170 }
1171 }
1172 }
1173
1174 #[test]
1175 fn ask_posture_prompts_for_non_auto_tools() {
1176 let auth = authority(AppMode::Agent, false, ApprovalMode::Suggest);
1177 for requirement in [ApprovalRequirement::Suggest, ApprovalRequirement::Required] {
1178 assert_eq!(
1179 resolve_tool_permission(&auth, requirement, false),
1180 ToolPermission::Prompt
1181 );
1182 assert_eq!(
1183 resolve_tool_permission(&auth, requirement, true),
1184 ToolPermission::Prompt
1185 );
1186 }
1187 }
1188
1189 #[test]
1190 fn full_access_allows_bypassable_but_prompts_for_non_bypassable() {
1191 for auth in [
1192 authority(AppMode::Agent, true, ApprovalMode::Bypass),
1193 TurnAuthority::for_tool_approval_decision(true),
1194 ] {
1195 for requirement in [ApprovalRequirement::Suggest, ApprovalRequirement::Required] {
1196 assert_eq!(
1197 resolve_tool_permission(&auth, requirement, false),
1198 ToolPermission::Allow,
1199 "generic {requirement:?} tool stays auto-approved in Full Access"
1200 );
1201 assert_eq!(
1202 resolve_tool_permission(&auth, requirement, true),
1203 ToolPermission::Allow,
1204 "non-bypassable {requirement:?} tool auto-approves in Full Access (#3866 reversed)"
1205 );
1206 }
1207 }
1208
1209 // Ask (the default suggest posture without auto-approve) can open the
1210 // modal, so the hold still prompts there.
1211 let ask = authority(AppMode::Agent, false, ApprovalMode::Suggest);
1212 for requirement in [ApprovalRequirement::Suggest, ApprovalRequirement::Required] {
1213 assert_eq!(
1214 resolve_tool_permission(&ask, requirement, true),
1215 ToolPermission::Prompt,
1216 "non-bypassable {requirement:?} tool still prompts in Ask"
1217 );
1218 }
1219 }
1220
1221 #[test]
1222 fn never_denies_promptable_tools_but_not_reads_or_full_access_shapes() {
1223 let never = authority(AppMode::Agent, false, ApprovalMode::Never);
1224 assert_eq!(
1225 resolve_tool_permission(&never, ApprovalRequirement::Suggest, false),
1226 ToolPermission::Deny
1227 );
1228 assert_eq!(
1229 resolve_tool_permission(&never, ApprovalRequirement::Required, true),
1230 ToolPermission::Deny
1231 );
1232 assert_eq!(
1233 resolve_tool_permission(&never, ApprovalRequirement::Auto, false),
1234 ToolPermission::Allow,
1235 "Never remains read-only rather than dead"
1236 );
1237
1238 // Legacy host shape: a full-access bit with a stale Never enum still
1239 // auto-approves — the UI's full-access shortcut ran before its Never
1240 // check.
1241 let stale = authority(AppMode::Agent, true, ApprovalMode::Never);
1242 assert_eq!(
1243 resolve_tool_permission(&stale, ApprovalRequirement::Suggest, false),
1244 ToolPermission::Allow
1245 );
1246 }
1247
1248 #[test]
1249 fn approval_request_disposition_preserves_legacy_branch_order() {
1250 let ask = authority(AppMode::Agent, false, ApprovalMode::Suggest);
1251 let auto = authority(AppMode::Agent, false, ApprovalMode::Auto);
1252 let full_access = authority(AppMode::Agent, true, ApprovalMode::Bypass);
1253 let never = authority(AppMode::Agent, false, ApprovalMode::Never);
1254
1255 // Session denial wins over everything, including full access.
1256 assert_eq!(
1257 resolve_approval_request_disposition(&full_access, true, true, false, false),
1258 ApprovalRequestDisposition::AutoDenySessionDenied
1259 );
1260 // Forced hold under full access fails closed instead of auto-approving.
1261 assert_eq!(
1262 resolve_approval_request_disposition(&full_access, true, false, true, false),
1263 ApprovalRequestDisposition::AutoDenyFullAccessPolicyHold
1264 );
1265 // Full access and session grants auto-approve ordinary requests.
1266 assert_eq!(
1267 resolve_approval_request_disposition(&full_access, false, false, false, false),
1268 ApprovalRequestDisposition::AutoApprove
1269 );
1270 assert_eq!(
1271 resolve_approval_request_disposition(&ask, true, false, false, false),
1272 ApprovalRequestDisposition::AutoApprove
1273 );
1274 // The live Never posture wins over a remembered session grant
1275 // (approvals J, CURRENT_DECISIONS §21), and denies everything else
1276 // promptable.
1277 assert_eq!(
1278 resolve_approval_request_disposition(&never, true, false, false, false),
1279 ApprovalRequestDisposition::AutoDenyNeverPosture
1280 );
1281 assert_eq!(
1282 resolve_approval_request_disposition(&never, false, false, false, false),
1283 ApprovalRequestDisposition::AutoDenyNeverPosture
1284 );
1285 for force_prompt in [false, true] {
1286 assert_eq!(
1287 resolve_approval_request_disposition(&auto, false, false, force_prompt, false),
1288 ApprovalRequestDisposition::AutoDenyAutoReview
1289 );
1290 }
1291 // Ask posture with no grant opens the modal.
1292 assert_eq!(
1293 resolve_approval_request_disposition(&ask, false, false, false, false),
1294 ApprovalRequestDisposition::Prompt
1295 );
1296 }
1297 }
1298
1298 lines RUST