Decouple request permissions feature and tool (#14426)

This commit is contained in:
Jack Mousseau
2026-03-12 14:47:08 -07:00
committed by GitHub
Unverified
parent bc48b9289a
commit a314c7d3ae
9 changed files with 482 additions and 35 deletions
+8 -5
View File
@@ -2306,7 +2306,7 @@ async fn request_permissions_emits_event_when_reject_policy_allows_requests() {
}
#[tokio::test]
async fn request_permissions_returns_empty_grant_when_reject_policy_blocks_requests() {
async fn request_permissions_is_auto_denied_when_reject_policy_blocks_tool_requests() {
let (session, mut turn_context, rx) = make_session_and_context_with_rx().await;
*session.active_turn.lock().await = Some(ActiveTurn::default());
Arc::get_mut(&mut turn_context)
@@ -2323,10 +2323,13 @@ async fn request_permissions_returns_empty_grant_when_reject_policy_blocks_reque
))
.expect("test setup should allow updating approval policy");
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let call_id = "call-1".to_string();
let response = session
.request_permissions(
&turn_context,
"call-1".to_string(),
turn_context.as_ref(),
call_id,
codex_protocol::request_permissions::RequestPermissionsArgs {
reason: Some("need network".to_string()),
permissions: codex_protocol::models::PermissionProfile {
@@ -2349,10 +2352,10 @@ async fn request_permissions_returns_empty_grant_when_reject_policy_blocks_reque
)
);
assert!(
tokio::time::timeout(StdDuration::from_millis(50), rx.recv())
tokio::time::timeout(StdDuration::from_millis(100), rx.recv())
.await
.is_err(),
"unexpected request_permissions event emitted",
"request_permissions should not emit an event when reject.request_permissions is set"
);
}
+79
View File
@@ -227,6 +227,85 @@ async fn guardian_allows_unified_exec_additional_permissions_requests_past_polic
);
}
#[tokio::test]
#[cfg(unix)]
async fn shell_handler_allows_sticky_turn_permissions_without_inline_request_permissions_feature() {
let (mut session, turn_context_raw) = make_session_and_context().await;
session
.features
.enable(Feature::RequestPermissionsTool)
.expect("test setup should allow enabling request permissions tool");
*session.active_turn.lock().await = Some(ActiveTurn::default());
{
let mut active_turn = session.active_turn.lock().await;
let active_turn = active_turn.as_mut().expect("active turn");
let mut turn_state = active_turn.turn_state.lock().await;
turn_state.record_granted_permissions(PermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
..Default::default()
});
}
let session = Arc::new(session);
let turn_context = Arc::new(turn_context_raw);
let handler = ShellHandler;
let resp = handler
.handle(ToolInvocation {
session: Arc::clone(&session),
turn: Arc::clone(&turn_context),
tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
call_id: "sticky-turn-grant".to_string(),
tool_name: "shell".to_string(),
tool_namespace: None,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"command": [
"/bin/sh",
"-c",
"echo hi",
],
"timeout_ms": 1_000_u64,
"workdir": Some(turn_context.cwd.to_string_lossy().to_string()),
})
.to_string(),
},
})
.await;
match resp {
Ok(output) => {
let output = expect_text_output(&output);
#[derive(Deserialize, PartialEq, Eq, Debug)]
struct ResponseExecMetadata {
exit_code: i32,
}
#[derive(Deserialize)]
struct ResponseExecOutput {
output: String,
metadata: ResponseExecMetadata,
}
let exec_output: ResponseExecOutput =
serde_json::from_str(&output).expect("valid exec output json");
assert_eq!(exec_output.metadata, ResponseExecMetadata { exit_code: 0 });
assert!(exec_output.output.contains("hi"));
}
Err(FunctionCallError::RespondToModel(output)) => {
assert!(
!output.contains("additional permissions are disabled"),
"sticky turn permissions should bypass inline validation: {output}"
);
}
Err(err) => panic!("unexpected error: {err:?}"),
}
}
#[tokio::test]
async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
let codex_home = tempdir().expect("create codex home");
+136 -3
View File
@@ -101,7 +101,7 @@ fn resolve_workdir_base_path(
/// Validates feature/policy constraints for `with_additional_permissions` and
/// normalizes any path-based permissions. Errors if the request is invalid.
pub(crate) fn normalize_and_validate_additional_permissions(
request_permission_enabled: bool,
additional_permissions_allowed: bool,
approval_policy: AskForApproval,
sandbox_permissions: SandboxPermissions,
additional_permissions: Option<PermissionProfile>,
@@ -113,11 +113,12 @@ pub(crate) fn normalize_and_validate_additional_permissions(
SandboxPermissions::WithAdditionalPermissions
);
if !request_permission_enabled
if !permissions_preapproved
&& !additional_permissions_allowed
&& (uses_additional_permissions || additional_permissions.is_some())
{
return Err(
"additional permissions are disabled; enable `features.request_permission` before using `with_additional_permissions`"
"additional permissions are disabled; enable `features.request_permissions` before using `with_additional_permissions`"
.to_string(),
);
}
@@ -164,6 +165,23 @@ pub(super) struct EffectiveAdditionalPermissions {
pub permissions_preapproved: bool,
}
pub(super) fn implicit_granted_permissions(
sandbox_permissions: SandboxPermissions,
additional_permissions: Option<&PermissionProfile>,
effective_additional_permissions: &EffectiveAdditionalPermissions,
) -> Option<PermissionProfile> {
if !sandbox_permissions.uses_additional_permissions()
&& !matches!(sandbox_permissions, SandboxPermissions::RequireEscalated)
&& additional_permissions.is_none()
{
effective_additional_permissions
.additional_permissions
.clone()
} else {
None
}
}
pub(super) async fn apply_granted_turn_permissions(
session: &Session,
sandbox_permissions: SandboxPermissions,
@@ -210,3 +228,118 @@ pub(super) async fn apply_granted_turn_permissions(
permissions_preapproved,
}
}
#[cfg(test)]
mod tests {
use super::EffectiveAdditionalPermissions;
use super::implicit_granted_permissions;
use super::normalize_and_validate_additional_permissions;
use crate::sandboxing::SandboxPermissions;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::RejectConfig;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
fn network_permissions() -> PermissionProfile {
PermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
..Default::default()
}
}
fn file_system_permissions(path: &std::path::Path) -> PermissionProfile {
PermissionProfile {
file_system: Some(FileSystemPermissions {
read: None,
write: Some(vec![
AbsolutePathBuf::from_absolute_path(path).expect("absolute path"),
]),
}),
..Default::default()
}
}
#[test]
fn preapproved_permissions_work_when_request_permissions_tool_is_enabled_without_inline_feature()
{
let cwd = tempdir().expect("tempdir");
let normalized = normalize_and_validate_additional_permissions(
false,
AskForApproval::Reject(RejectConfig {
sandbox_approval: false,
rules: false,
skill_approval: false,
request_permissions: true,
mcp_elicitations: false,
}),
SandboxPermissions::WithAdditionalPermissions,
Some(network_permissions()),
true,
cwd.path(),
)
.expect("preapproved permissions should be allowed");
assert_eq!(normalized, Some(network_permissions()));
}
#[test]
fn fresh_additional_permissions_still_require_request_permissions_feature() {
let cwd = tempdir().expect("tempdir");
let err = normalize_and_validate_additional_permissions(
false,
AskForApproval::OnRequest,
SandboxPermissions::WithAdditionalPermissions,
Some(network_permissions()),
false,
cwd.path(),
)
.expect_err("fresh inline permission requests should remain disabled");
assert_eq!(
err,
"additional permissions are disabled; enable `features.request_permissions` before using `with_additional_permissions`"
);
}
#[test]
fn implicit_sticky_grants_bypass_inline_permission_validation() {
let cwd = tempdir().expect("tempdir");
let granted_permissions = file_system_permissions(cwd.path());
let implicit_permissions = implicit_granted_permissions(
SandboxPermissions::UseDefault,
None,
&EffectiveAdditionalPermissions {
sandbox_permissions: SandboxPermissions::WithAdditionalPermissions,
additional_permissions: Some(granted_permissions.clone()),
permissions_preapproved: false,
},
);
assert_eq!(implicit_permissions, Some(granted_permissions));
}
#[test]
fn explicit_inline_permissions_do_not_use_implicit_sticky_grant_path() {
let cwd = tempdir().expect("tempdir");
let requested_permissions = file_system_permissions(cwd.path());
let implicit_permissions = implicit_granted_permissions(
SandboxPermissions::WithAdditionalPermissions,
Some(&requested_permissions),
&EffectiveAdditionalPermissions {
sandbox_permissions: SandboxPermissions::WithAdditionalPermissions,
additional_permissions: Some(requested_permissions.clone()),
permissions_preapproved: false,
},
);
assert_eq!(implicit_permissions, None);
}
}
+22 -7
View File
@@ -21,6 +21,7 @@ use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx;
use crate::tools::handlers::apply_granted_turn_permissions;
use crate::tools::handlers::apply_patch::intercept_apply_patch;
use crate::tools::handlers::implicit_granted_permissions;
use crate::tools::handlers::normalize_and_validate_additional_permissions;
use crate::tools::handlers::parse_arguments_with_base_path;
use crate::tools::handlers::resolve_workdir_base_path;
@@ -336,19 +337,33 @@ impl ShellHandler {
}
let request_permission_enabled = session.features().enabled(Feature::RequestPermissions);
let requested_additional_permissions = additional_permissions.clone();
let effective_additional_permissions = apply_granted_turn_permissions(
session.as_ref(),
exec_params.sandbox_permissions,
additional_permissions,
)
.await;
let normalized_additional_permissions = normalize_and_validate_additional_permissions(
request_permission_enabled,
turn.approval_policy.value(),
effective_additional_permissions.sandbox_permissions,
effective_additional_permissions.additional_permissions,
effective_additional_permissions.permissions_preapproved,
&exec_params.cwd,
let additional_permissions_allowed = request_permission_enabled
|| (session.features().enabled(Feature::RequestPermissionsTool)
&& effective_additional_permissions.permissions_preapproved);
let normalized_additional_permissions = implicit_granted_permissions(
exec_params.sandbox_permissions,
requested_additional_permissions.as_ref(),
&effective_additional_permissions,
)
.map_or_else(
|| {
normalize_and_validate_additional_permissions(
additional_permissions_allowed,
turn.approval_policy.value(),
effective_additional_permissions.sandbox_permissions,
effective_additional_permissions.additional_permissions,
effective_additional_permissions.permissions_preapproved,
&exec_params.cwd,
)
},
|permissions| Ok(Some(permissions)),
)
.map_err(FunctionCallError::RespondToModel)?;
@@ -12,6 +12,7 @@ use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::apply_granted_turn_permissions;
use crate::tools::handlers::apply_patch::intercept_apply_patch;
use crate::tools::handlers::implicit_granted_permissions;
use crate::tools::handlers::normalize_and_validate_additional_permissions;
use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::parse_arguments_with_base_path;
@@ -171,12 +172,16 @@ impl ToolHandler for UnifiedExecHandler {
let request_permission_enabled =
session.features().enabled(Feature::RequestPermissions);
let requested_additional_permissions = additional_permissions.clone();
let effective_additional_permissions = apply_granted_turn_permissions(
context.session.as_ref(),
sandbox_permissions,
additional_permissions,
)
.await;
let additional_permissions_allowed = request_permission_enabled
|| (session.features().enabled(Feature::RequestPermissionsTool)
&& effective_additional_permissions.permissions_preapproved);
// Sticky turn permissions have already been approved, so they should
// continue through the normal exec approval flow for the command.
@@ -200,21 +205,30 @@ impl ToolHandler for UnifiedExecHandler {
let workdir = workdir.map(|dir| context.turn.resolve_path(Some(dir)));
let cwd = workdir.clone().unwrap_or(cwd);
let normalized_additional_permissions =
match normalize_and_validate_additional_permissions(
request_permission_enabled,
context.turn.approval_policy.value(),
effective_additional_permissions.sandbox_permissions,
effective_additional_permissions.additional_permissions,
effective_additional_permissions.permissions_preapproved,
&cwd,
) {
Ok(normalized) => normalized,
Err(err) => {
manager.release_process_id(process_id).await;
return Err(FunctionCallError::RespondToModel(err));
}
};
let normalized_additional_permissions = match implicit_granted_permissions(
sandbox_permissions,
requested_additional_permissions.as_ref(),
&effective_additional_permissions,
)
.map_or_else(
|| {
normalize_and_validate_additional_permissions(
additional_permissions_allowed,
context.turn.approval_policy.value(),
effective_additional_permissions.sandbox_permissions,
effective_additional_permissions.additional_permissions,
effective_additional_permissions.permissions_preapproved,
&cwd,
)
},
|permissions| Ok(Some(permissions)),
) {
Ok(normalized) => normalized,
Err(err) => {
manager.release_process_id(process_id).await;
return Err(FunctionCallError::RespondToModel(err));
}
};
if let Some(output) = intercept_apply_patch(
&command,