use std::ffi::OsStr;
use std::path::{Component, Path, PathBuf};
use crate::sandbox::SandboxPolicy;
use crate::tools::spec::{ApprovalRequirement, normalize_path};
use crate::tui::app::AppMode;
use crate::tui::approval::ApprovalMode;
use crate::worker_profile::ShellPolicy;
use super::ops::UserInputProvenance;
#[derive(Debug, Clone, Copy)]
pub(crate) struct ModeSessionPrefs {
pub(crate) agent_allow_shell: bool,
pub(crate) agent_trust_mode: bool,
pub(crate) agent_approval_mode: ApprovalMode,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct EffectiveModePolicy {
#[allow(dead_code)]
pub(crate) mode: AppMode,
pub(crate) allow_shell: bool,
pub(crate) trust_mode: bool,
pub(crate) approval_mode: ApprovalMode,
}
#[must_use]
pub(crate) fn base_policy_for_mode(mode: AppMode, prefs: &ModeSessionPrefs) -> EffectiveModePolicy {
match mode {
AppMode::Plan => EffectiveModePolicy {
mode,
allow_shell: false,
trust_mode: false,
approval_mode: ApprovalMode::Suggest,
},
AppMode::Agent | AppMode::Auto | AppMode::Operate => EffectiveModePolicy {
mode,
allow_shell: prefs.agent_allow_shell,
trust_mode: prefs.agent_trust_mode,
approval_mode: prefs.agent_approval_mode,
},
AppMode::Yolo => EffectiveModePolicy {
mode,
allow_shell: true,
trust_mode: true,
approval_mode: ApprovalMode::Bypass,
},
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PolicyNarrowingReason {
NonAuthoritativeProvenance,
}
impl PolicyNarrowingReason {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::NonAuthoritativeProvenance => "non_authoritative_provenance",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PolicyNarrowingEvent {
reason: PolicyNarrowingReason,
from_mode: &'static str,
to_mode: &'static str,
from_approval: ApprovalMode,
to_approval: ApprovalMode,
detail: String,
}
impl PolicyNarrowingEvent {
pub(crate) fn reason(&self) -> PolicyNarrowingReason {
self.reason
}
pub(crate) fn message(&self) -> String {
match self.reason {
PolicyNarrowingReason::NonAuthoritativeProvenance => format!(
"Input provenance '{}' cannot inherit standing auto-approval authority; continuing with approvals required.",
self.detail
),
}
}
pub(crate) fn transition(&self) -> String {
format!(
"{} ({}) -> {} ({})",
self.from_mode,
self.from_approval.permission_chip_label(),
self.to_mode,
self.to_approval.permission_chip_label(),
)
}
}
#[derive(Debug, Clone)]
pub(crate) struct TurnAuthority {
pub(crate) mode: AppMode,
pub(crate) allow_shell: bool,
pub(crate) trust_mode: bool,
pub(crate) auto_approve: bool,
pub(crate) approval_mode: ApprovalMode,
pub(crate) dynamic_active_tools: Vec<&'static str>,
pub(crate) narrowing: Option<PolicyNarrowingEvent>,
}
impl TurnAuthority {
pub(crate) fn status(&self) -> Option<String> {
self.narrowing.as_ref().map(PolicyNarrowingEvent::message)
}
#[must_use]
pub(crate) fn from_effective_fields(
mode: AppMode,
allow_shell: bool,
trust_mode: bool,
auto_approve: bool,
approval_mode: ApprovalMode,
) -> Self {
Self {
mode,
allow_shell,
trust_mode,
auto_approve,
approval_mode,
dynamic_active_tools: Vec::new(),
narrowing: None,
}
}
#[must_use]
pub(crate) fn approval_mode_for_session(&self) -> ApprovalMode {
agent_approval_mode_for_turn(self.auto_approve, self.approval_mode)
}
#[must_use]
pub(crate) fn for_tool_approval_decision(auto_approve: bool) -> Self {
Self::from_effective_fields(
if auto_approve {
AppMode::Yolo
} else {
AppMode::Agent
},
true,
false,
auto_approve,
if auto_approve {
ApprovalMode::Bypass
} else {
ApprovalMode::Suggest
},
)
}
#[must_use]
pub(crate) fn shell_policy(&self) -> ShellPolicy {
shell_policy_for_mode(self.mode, self.allow_shell)
}
#[must_use]
pub(crate) fn sandbox_policy(
&self,
workspace: &Path,
configured_mode: Option<&str>,
) -> SandboxPolicy {
sandbox_policy_for_turn(
self.mode,
self.approval_mode_for_session(),
configured_mode,
workspace,
)
}
}
#[must_use]
pub(crate) fn effective_input_policy(
provenance: UserInputProvenance,
requested_mode: AppMode,
_content: &str,
allow_shell: bool,
trust_mode: bool,
auto_approve: bool,
approval_mode: ApprovalMode,
) -> TurnAuthority {
let mut mode = requested_mode;
let mut trust_mode = trust_mode;
let mut auto_approve = auto_approve;
let mut approval_mode = approval_mode;
let mut narrowing = None;
if !provenance_can_inherit_standing_auto_authority(provenance) {
let from_mode = mode;
let from_approval = approval_mode;
let had_auto_authority = matches!(mode, AppMode::Yolo)
|| trust_mode
|| auto_approve
|| matches!(approval_mode, ApprovalMode::Bypass);
if matches!(mode, AppMode::Yolo) {
mode = AppMode::Agent;
}
trust_mode = false;
auto_approve = false;
if matches!(approval_mode, ApprovalMode::Auto | ApprovalMode::Bypass) {
approval_mode = ApprovalMode::Suggest;
}
if had_auto_authority {
narrowing = Some(PolicyNarrowingEvent {
reason: PolicyNarrowingReason::NonAuthoritativeProvenance,
from_mode: from_mode.as_setting(),
to_mode: mode.as_setting(),
from_approval,
to_approval: approval_mode,
detail: provenance.as_str().to_string(),
});
}
}
if approval_mode == ApprovalMode::Bypass {
auto_approve = true;
}
TurnAuthority {
mode,
allow_shell,
trust_mode,
auto_approve,
approval_mode,
dynamic_active_tools: Vec::new(),
narrowing,
}
}
#[must_use]
pub(crate) fn provenance_can_inherit_standing_auto_authority(
provenance: UserInputProvenance,
) -> bool {
matches!(
provenance,
UserInputProvenance::ExternalUser
| UserInputProvenance::Runtime
| UserInputProvenance::SubAgentHandoff
)
}
#[must_use]
pub(crate) fn permission_posture_allows_questions(approval_mode: ApprovalMode) -> bool {
approval_mode != ApprovalMode::Auto
}
#[must_use]
pub(crate) fn agent_approval_mode_for_turn(
auto_approve: bool,
approval_mode: ApprovalMode,
) -> ApprovalMode {
if auto_approve {
ApprovalMode::Bypass
} else {
approval_mode
}
}
#[must_use]
pub(crate) fn sandbox_policy_for_turn(
mode: AppMode,
approval_mode: ApprovalMode,
configured_mode: Option<&str>,
workspace: &Path,
) -> SandboxPolicy {
let default = if mode == AppMode::Plan {
SandboxPolicy::ReadOnly
} else if mode == AppMode::Yolo || approval_mode == ApprovalMode::Bypass {
SandboxPolicy::DangerFullAccess
} else {
workspace_write_policy(workspace)
};
match (default, configured_mode) {
(SandboxPolicy::ReadOnly, _) | (_, Some("read-only")) => SandboxPolicy::ReadOnly,
(SandboxPolicy::DangerFullAccess, Some("workspace-write")) => {
workspace_write_policy(workspace)
}
(SandboxPolicy::DangerFullAccess, Some("external-sandbox")) => {
SandboxPolicy::ExternalSandbox {
network_access: true,
}
}
(policy, _) => policy,
}
}
fn workspace_write_policy(workspace: &Path) -> SandboxPolicy {
SandboxPolicy::WorkspaceWrite {
writable_roots: vec![workspace.to_path_buf()],
network_access: true,
exclude_tmpdir: false,
exclude_slash_tmp: false,
}
}
#[must_use]
pub(crate) fn shell_policy_for_mode(mode: AppMode, allow_shell: bool) -> ShellPolicy {
if !allow_shell {
return ShellPolicy::None;
}
match mode {
AppMode::Plan => ShellPolicy::None,
AppMode::Agent | AppMode::Auto | AppMode::Operate | AppMode::Yolo => ShellPolicy::Full,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ToolPermission {
Allow,
Prompt,
Deny,
}
#[must_use]
pub(crate) fn resolve_tool_permission(
authority: &TurnAuthority,
requirement: ApprovalRequirement,
is_non_bypassable: bool,
) -> ToolPermission {
if authority.approval_mode == ApprovalMode::Never
&& requirement != ApprovalRequirement::Auto
&& !authority.auto_approve
&& authority.mode != AppMode::Yolo
{
return ToolPermission::Deny;
}
match requirement {
ApprovalRequirement::Auto => ToolPermission::Allow,
ApprovalRequirement::Suggest | ApprovalRequirement::Required => {
if is_non_bypassable {
return if authority.auto_approve || authority.mode == AppMode::Yolo {
ToolPermission::Allow
} else {
ToolPermission::Prompt
};
}
if authority.auto_approve
|| authority.approval_mode == ApprovalMode::Bypass
|| authority.mode == AppMode::Yolo
{
ToolPermission::Allow
} else {
ToolPermission::Prompt
}
}
}
}
#[must_use]
pub(crate) fn write_carve_out_posture(
mode: AppMode,
approval_mode: ApprovalMode,
auto_approve: bool,
) -> bool {
!auto_approve
&& matches!(mode, AppMode::Agent | AppMode::Auto | AppMode::Operate)
&& approval_mode == ApprovalMode::Suggest
}
#[must_use]
pub(crate) fn paths_within_workspace_write_carve_out(workspace: &Path, paths: &[String]) -> bool {
if paths.is_empty() {
return false;
}
if workspace.join(".git").symlink_metadata().is_err() {
return false;
}
let Ok(workspace_canonical) = workspace.canonicalize() else {
return false;
};
paths
.iter()
.all(|raw| carve_out_target_allowed(workspace, &workspace_canonical, raw))
}
fn carve_out_target_allowed(workspace: &Path, workspace_canonical: &Path, raw: &str) -> bool {
let raw = raw.trim();
if raw.is_empty() {
return false;
}
let raw_path = Path::new(raw);
let candidate = if raw_path.is_absolute() {
raw_path.to_path_buf()
} else {
workspace.join(raw_path)
};
let lexical = normalize_path(&candidate);
let workspace_lexical = normalize_path(workspace);
let workspace_canonical_lexical = normalize_path(workspace_canonical);
let Ok(relative) = lexical
.strip_prefix(&workspace_lexical)
.or_else(|_| lexical.strip_prefix(&workspace_canonical_lexical))
else {
return false;
};
if !carve_out_relative_path_allowed(relative) {
return false;
}
let Some(resolved) = resolve_deepest_existing(&candidate) else {
return false;
};
let Ok(resolved_relative) = resolved.strip_prefix(workspace_canonical) else {
return false;
};
carve_out_relative_path_allowed(resolved_relative)
}
fn resolve_deepest_existing(candidate: &Path) -> Option<PathBuf> {
let mut ancestor = candidate;
let mut suffix: Vec<&OsStr> = Vec::new();
loop {
if let Ok(canonical) = ancestor.canonicalize() {
let mut resolved = canonical;
for part in suffix.iter().rev() {
resolved.push(part);
}
return Some(resolved);
}
suffix.push(ancestor.file_name()?);
ancestor = ancestor.parent()?;
}
}
fn carve_out_relative_path_allowed(relative: &Path) -> bool {
relative.components().all(|component| {
let Component::Normal(part) = component else {
return true;
};
!is_carve_out_excluded_name(&part.to_string_lossy().to_ascii_lowercase())
})
}
fn is_carve_out_excluded_name(name: &str) -> bool {
if name == ".git" {
return true;
}
if matches!(
name,
".codewhale" | ".ssh" | ".aws" | ".gnupg" | ".kube" | ".docker"
) {
return true;
}
if name.starts_with(".env")
|| name == ".netrc"
|| name == ".npmrc"
|| name == ".pypirc"
|| name == ".git-credentials"
|| name == "credentials"
|| name.starts_with("credentials.")
{
return true;
}
if name.starts_with("id_rsa")
|| name.starts_with("id_dsa")
|| name.starts_with("id_ecdsa")
|| name.starts_with("id_ed25519")
{
return true;
}
matches!(
Path::new(name).extension().and_then(|ext| ext.to_str()),
Some("pem" | "key" | "p12" | "pfx" | "jks" | "keystore")
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ApprovalRequestDisposition {
AutoApprove,
AutoDenySessionDenied,
AutoDenyFullAccessPolicyHold,
AutoDenyAutoReview,
AutoDenyNeverPosture,
Prompt,
}
#[must_use]
pub(crate) fn resolve_approval_request_disposition(
authority: &TurnAuthority,
session_approved: bool,
session_denied: bool,
approval_force_prompt: bool,
) -> ApprovalRequestDisposition {
if session_denied {
return ApprovalRequestDisposition::AutoDenySessionDenied;
}
if authority.approval_mode_for_session() == ApprovalMode::Auto {
return ApprovalRequestDisposition::AutoDenyAutoReview;
}
let posture = resolve_tool_permission(authority, ApprovalRequirement::Suggest, false);
if approval_force_prompt && posture == ToolPermission::Allow {
return ApprovalRequestDisposition::AutoDenyFullAccessPolicyHold;
}
if !approval_force_prompt && (posture == ToolPermission::Allow || session_approved) {
return ApprovalRequestDisposition::AutoApprove;
}
if posture == ToolPermission::Deny {
return ApprovalRequestDisposition::AutoDenyNeverPosture;
}
ApprovalRequestDisposition::Prompt
}
#[cfg(test)]
mod tests {
use super::*;
fn authority(mode: AppMode, auto_approve: bool, approval_mode: ApprovalMode) -> TurnAuthority {
TurnAuthority::from_effective_fields(mode, true, false, auto_approve, approval_mode)
}
#[test]
fn write_carve_out_posture_is_exactly_the_default_ask_posture() {
assert!(write_carve_out_posture(
AppMode::Agent,
ApprovalMode::Suggest,
false
));
assert!(write_carve_out_posture(
AppMode::Operate,
ApprovalMode::Suggest,
false
));
assert!(!write_carve_out_posture(
AppMode::Agent,
ApprovalMode::Bypass,
true
));
assert!(!write_carve_out_posture(
AppMode::Yolo,
ApprovalMode::Bypass,
true
));
assert!(!write_carve_out_posture(
AppMode::Agent,
ApprovalMode::Never,
false
));
assert!(!write_carve_out_posture(
AppMode::Agent,
ApprovalMode::Auto,
false
));
assert!(!write_carve_out_posture(
AppMode::Plan,
ApprovalMode::Suggest,
false
));
}
fn carve_out_workspace() -> tempfile::TempDir {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(tmp.path().join(".git")).expect("git marker");
std::fs::create_dir_all(tmp.path().join("src")).expect("src dir");
std::fs::write(tmp.path().join("src/main.rs"), "fn main() {}\n").expect("source file");
tmp
}
#[test]
fn carve_out_allows_in_workspace_write_targets() {
let tmp = carve_out_workspace();
let workspace = tmp.path();
for paths in [
vec!["src/main.rs".to_string()],
vec!["src/new_file.rs".to_string()],
vec!["deeply/nested/not-yet-created.rs".to_string()],
vec!["./src/main.rs".to_string()],
vec![workspace.join("src/main.rs").to_string_lossy().into_owned()],
vec!["src/main.rs".to_string(), "src/other.rs".to_string()],
] {
assert!(
paths_within_workspace_write_carve_out(workspace, &paths),
"{paths:?} should qualify"
);
}
}
#[test]
fn carve_out_rejects_out_of_tree_sensitive_and_git_paths() {
let tmp = carve_out_workspace();
let workspace = tmp.path();
for paths in [
vec!["../outside.rs".to_string()],
vec!["src/../../outside.rs".to_string()],
vec!["/etc/passwd".to_string()],
vec![".git/config".to_string()],
vec!["nested/.git/hooks/pre-commit".to_string()],
vec![".env".to_string()],
vec!["config/.env.production".to_string()],
vec![".ssh/config".to_string()],
vec!["deploy/id_rsa".to_string()],
vec!["certs/server.pem".to_string()],
vec![".codewhale/mcp.json".to_string()],
vec!["aws/credentials".to_string()],
vec!["src/main.rs".to_string(), ".env".to_string()],
] {
assert!(
!paths_within_workspace_write_carve_out(workspace, &paths),
"{paths:?} must keep the modal"
);
}
}
#[test]
fn carve_out_requires_a_git_work_tree() {
let tmp = tempfile::tempdir().expect("tempdir");
assert!(!paths_within_workspace_write_carve_out(
tmp.path(),
&["src/main.rs".to_string()]
));
}
#[test]
fn carve_out_rejects_empty_target_list() {
let tmp = carve_out_workspace();
assert!(!paths_within_workspace_write_carve_out(tmp.path(), &[]));
}
#[cfg(unix)]
#[test]
fn carve_out_rejects_symlink_escapes() {
let tmp = carve_out_workspace();
let outside = tempfile::tempdir().expect("outside tempdir");
std::os::unix::fs::symlink(outside.path(), tmp.path().join("link")).expect("symlink");
assert!(!paths_within_workspace_write_carve_out(
tmp.path(),
&["link/evil.rs".to_string()]
));
std::os::unix::fs::symlink(tmp.path().join("src"), tmp.path().join("src-link"))
.expect("inner symlink");
assert!(paths_within_workspace_write_carve_out(
tmp.path(),
&["src-link/main.rs".to_string()]
));
}
#[test]
fn full_access_is_unsandboxed_unless_effective_config_is_stricter() {
let workspace = Path::new("/work");
let full_access = authority(AppMode::Agent, true, ApprovalMode::Bypass);
assert_eq!(
full_access.sandbox_policy(workspace, None),
SandboxPolicy::DangerFullAccess
);
assert!(matches!(
full_access.sandbox_policy(workspace, Some("workspace-write")),
SandboxPolicy::WorkspaceWrite { writable_roots, .. }
if writable_roots == vec![workspace.to_path_buf()]
));
assert_eq!(
full_access.sandbox_policy(workspace, Some("read-only")),
SandboxPolicy::ReadOnly
);
assert!(matches!(
full_access.sandbox_policy(workspace, Some("external-sandbox")),
SandboxPolicy::ExternalSandbox {
network_access: true
}
));
}
#[test]
fn plan_ask_and_auto_review_cannot_be_loosened_by_sandbox_config() {
let workspace = Path::new("/work");
for approval_mode in [ApprovalMode::Suggest, ApprovalMode::Auto] {
let authority = authority(AppMode::Agent, false, approval_mode);
assert!(matches!(
authority.sandbox_policy(workspace, Some("danger-full-access")),
SandboxPolicy::WorkspaceWrite { .. }
));
}
let plan = authority(AppMode::Plan, true, ApprovalMode::Bypass);
assert_eq!(
plan.sandbox_policy(workspace, Some("danger-full-access")),
SandboxPolicy::ReadOnly
);
}
#[test]
fn auto_requirement_always_allows() {
for (mode, auto_approve, approval_mode) in [
(AppMode::Agent, false, ApprovalMode::Suggest),
(AppMode::Agent, false, ApprovalMode::Auto),
(AppMode::Agent, false, ApprovalMode::Never),
(AppMode::Agent, true, ApprovalMode::Bypass),
(AppMode::Yolo, true, ApprovalMode::Bypass),
(AppMode::Plan, false, ApprovalMode::Suggest),
] {
let auth = authority(mode, auto_approve, approval_mode);
for non_bypassable in [false, true] {
assert_eq!(
resolve_tool_permission(&auth, ApprovalRequirement::Auto, non_bypassable),
ToolPermission::Allow,
"{mode:?}/{auto_approve}/{approval_mode:?}/nb={non_bypassable}"
);
}
}
}
#[test]
fn ask_posture_prompts_for_non_auto_tools() {
let auth = authority(AppMode::Agent, false, ApprovalMode::Suggest);
for requirement in [ApprovalRequirement::Suggest, ApprovalRequirement::Required] {
assert_eq!(
resolve_tool_permission(&auth, requirement, false),
ToolPermission::Prompt
);
assert_eq!(
resolve_tool_permission(&auth, requirement, true),
ToolPermission::Prompt
);
}
}
#[test]
fn full_access_allows_bypassable_but_prompts_for_non_bypassable() {
for auth in [
authority(AppMode::Agent, true, ApprovalMode::Bypass),
authority(AppMode::Yolo, true, ApprovalMode::Bypass),
TurnAuthority::for_tool_approval_decision(true),
] {
for requirement in [ApprovalRequirement::Suggest, ApprovalRequirement::Required] {
assert_eq!(
resolve_tool_permission(&auth, requirement, false),
ToolPermission::Allow,
"generic {requirement:?} tool stays auto-approved in Full Access"
);
assert_eq!(
resolve_tool_permission(&auth, requirement, true),
ToolPermission::Allow,
"non-bypassable {requirement:?} tool auto-approves in Full Access (#3866 reversed)"
);
}
}
let ask = authority(AppMode::Agent, false, ApprovalMode::Suggest);
for requirement in [ApprovalRequirement::Suggest, ApprovalRequirement::Required] {
assert_eq!(
resolve_tool_permission(&ask, requirement, true),
ToolPermission::Prompt,
"non-bypassable {requirement:?} tool still prompts in Ask"
);
}
}
#[test]
fn never_denies_promptable_tools_but_not_reads_or_full_access_shapes() {
let never = authority(AppMode::Agent, false, ApprovalMode::Never);
assert_eq!(
resolve_tool_permission(&never, ApprovalRequirement::Suggest, false),
ToolPermission::Deny
);
assert_eq!(
resolve_tool_permission(&never, ApprovalRequirement::Required, true),
ToolPermission::Deny
);
assert_eq!(
resolve_tool_permission(&never, ApprovalRequirement::Auto, false),
ToolPermission::Allow,
"Never remains read-only rather than dead"
);
let stale = authority(AppMode::Agent, true, ApprovalMode::Never);
assert_eq!(
resolve_tool_permission(&stale, ApprovalRequirement::Suggest, false),
ToolPermission::Allow
);
let yolo_never = authority(AppMode::Yolo, false, ApprovalMode::Never);
assert_eq!(
resolve_tool_permission(&yolo_never, ApprovalRequirement::Suggest, false),
ToolPermission::Allow
);
}
#[test]
fn approval_request_disposition_preserves_legacy_branch_order() {
let ask = authority(AppMode::Agent, false, ApprovalMode::Suggest);
let auto = authority(AppMode::Agent, false, ApprovalMode::Auto);
let full_access = authority(AppMode::Agent, true, ApprovalMode::Bypass);
let never = authority(AppMode::Agent, false, ApprovalMode::Never);
assert_eq!(
resolve_approval_request_disposition(&full_access, true, true, false),
ApprovalRequestDisposition::AutoDenySessionDenied
);
assert_eq!(
resolve_approval_request_disposition(&full_access, true, false, true),
ApprovalRequestDisposition::AutoDenyFullAccessPolicyHold
);
assert_eq!(
resolve_approval_request_disposition(&full_access, false, false, false),
ApprovalRequestDisposition::AutoApprove
);
assert_eq!(
resolve_approval_request_disposition(&ask, true, false, false),
ApprovalRequestDisposition::AutoApprove
);
assert_eq!(
resolve_approval_request_disposition(&never, true, false, false),
ApprovalRequestDisposition::AutoApprove
);
assert_eq!(
resolve_approval_request_disposition(&never, false, false, false),
ApprovalRequestDisposition::AutoDenyNeverPosture
);
for force_prompt in [false, true] {
assert_eq!(
resolve_approval_request_disposition(&auto, false, false, force_prompt),
ApprovalRequestDisposition::AutoDenyAutoReview
);
}
assert_eq!(
resolve_approval_request_disposition(&ask, false, false, false),
ApprovalRequestDisposition::Prompt
);
}
}