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