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