Resolve permission requests in the executor context (#42146)

## Why

`request_permissions` paths and grants need to be evaluated against the selected executor environment, including its path convention, home directory, workspace roots, and temporary directories.

## What changed

- Resolve relative and home-relative permission paths using the executor context, reject mismatched path conventions and lossy paths, and support legacy `read` and `write` path lists.
- Move grant intersection into core so requested and granted permissions use the originating environment's sandbox context. Preserve deny entries conservatively when a special path cannot be resolved.
- Keep the full originating environment with pending permission requests so delayed responses are normalized against the same context.

## Testing

- Cover POSIX, Windows, UNC, relative, and home-relative path resolution and invalid path contexts.
- Verify end-to-end app-server grants are limited to the requested workspace scope and unresolved temporary-directory denies are preserved.

GitOrigin-RevId: 730a2aacd391262e92a6314f3a5b6c262e3dca10
This commit is contained in:
iceweasel-oai authored and copyberry committed 2026-09-01 22:08:08 +00:00
1 parent 86b7d12742
commit cd8bd62c6e
10 files changed
+492 -307

No files matched your search

+53 -34
View File
@@ -2,7 +2,6 @@ use std::borrow::Cow;
use std::collections::HashMap;
use std::collections::HashSet;
use std::fmt::Debug;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
@@ -121,6 +120,7 @@ use codex_protocol::models::SandboxEnforcement;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSandboxPolicyContext;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::HasLegacyEvent;
@@ -156,7 +156,7 @@ use codex_rollout::state_db;
use codex_rollout_trace::AgentResultTracePayload;
use codex_rollout_trace::ThreadStartedTraceMetadata;
use codex_rollout_trace::ThreadTraceContext;
use codex_sandboxing::policy_transforms::intersect_permission_profiles;
use codex_sandboxing::policy_transforms::intersect_permission_profiles_with_context;
use codex_shell_command::parse_command::parse_command;
use codex_terminal_detection::user_agent;
use codex_thread_store::CreateThreadParams;
@@ -2757,12 +2757,27 @@ impl Session {
step_context: &Arc<StepContext>,
call_id: String,
args: RequestPermissionsArgs,
environment: TurnEnvironmentSelection,
environment_selection: TurnEnvironmentSelection,
cancellation_token: CancellationToken,
) -> Option<RequestPermissionsResponse> {
let turn_context = &step_context.turn;
let approval_policy = step_context.settings.approval_policy();
let approvals_reviewer = step_context.settings.approvals_reviewer();
let Some(environment) = step_context
.environments
.turn_environments()
.find(|candidate| {
candidate.selection.environment_id == environment_selection.environment_id
})
.cloned()
else {
return Some(RequestPermissionsResponse {
permissions: RequestPermissionProfile::default(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
});
};
let cwd = environment.cwd();
match approval_policy {
AskForApproval::Never => {
return Some(RequestPermissionsResponse {
@@ -2786,19 +2801,14 @@ impl Session {
}
let requested_permissions = args.permissions;
// TODO(anp): Migrate request_permissions to support paths from foreign environments.
let Ok(native_environment_cwd) = environment.cwd.to_abs_path() else {
warn!(
cwd = %environment.cwd,
"request_permissions requires a cwd native to the Codex host"
);
let sandbox_context = environment.sandbox_context(/*additional_permissions*/ None);
let Some(context) = sandbox_context.policy_context() else {
return Some(RequestPermissionsResponse {
permissions: RequestPermissionProfile::default(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
});
};
if crate::guardian::routes_approval_policy_to_guardian(approval_policy, approvals_reviewer)
{
let originating_turn_state = {
@@ -2868,17 +2878,30 @@ impl Session {
let response = Self::normalize_request_permissions_response(
requested_permissions,
response,
native_environment_cwd.as_path(),
&context,
);
self.record_granted_request_permissions_for_turn(
&response,
&environment.environment_id,
&environment.selection.environment_id,
originating_turn_state.as_ref(),
)
.await;
return Some(response);
}
// The interactive approval event remains host-native until its public
// app-server/TUI boundary migrates in the next stack stage.
let Ok(native_cwd) = cwd.to_abs_path() else {
warn!(
cwd = %cwd,
"request_permissions interactive approval requires a cwd native to the Codex host"
);
return Some(RequestPermissionsResponse {
permissions: RequestPermissionProfile::default(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
});
};
let _elicitation = self.services.elicitations.register();
let (tx_response, rx_response) = oneshot::channel();
let prev_entry = {
@@ -2905,11 +2928,11 @@ impl Session {
let event = EventMsg::RequestPermissions(RequestPermissionsEvent {
call_id: call_id.clone(),
turn_id: turn_context.sub_id.clone(),
environment_id: Some(environment.environment_id.clone()),
environment_id: Some(environment.selection.environment_id.clone()),
started_at_ms: now_unix_timestamp_ms(),
reason: args.reason,
permissions: requested_permissions,
cwd: Some(native_environment_cwd),
cwd: Some(native_cwd),
});
self.send_event(turn_context.as_ref(), event).await;
tokio::select! {
@@ -3020,29 +3043,25 @@ impl Session {
};
match entry {
Some(entry) => {
// TODO(anp): Migrate request_permissions to support paths from foreign environments.
let response = match entry.environment.cwd.to_abs_path() {
Ok(native_environment_cwd) => Self::normalize_request_permissions_response(
let sandbox_context = entry
.environment
.sandbox_context(/*additional_permissions*/ None);
let response = if let Some(context) = sandbox_context.policy_context() {
Self::normalize_request_permissions_response(
entry.requested_permissions,
response,
native_environment_cwd.as_path(),
),
Err(err) => {
warn!(
cwd = %entry.environment.cwd,
%err,
"request_permissions requires a cwd native to the Codex host"
);
RequestPermissionsResponse {
permissions: RequestPermissionProfile::default(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
}
&context,
)
} else {
RequestPermissionsResponse {
permissions: RequestPermissionProfile::default(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
}
};
self.record_granted_request_permissions_for_turn(
&response,
&entry.environment.environment_id,
&entry.environment.selection.environment_id,
originating_turn_state.as_ref(),
)
.await;
@@ -3057,7 +3076,7 @@ impl Session {
fn normalize_request_permissions_response(
requested_permissions: RequestPermissionProfile,
response: RequestPermissionsResponse,
cwd: &Path,
context: &FileSystemSandboxPolicyContext<'_>,
) -> RequestPermissionsResponse {
if response.strict_auto_review && matches!(response.scope, PermissionGrantScope::Session) {
return RequestPermissionsResponse {
@@ -3072,10 +3091,10 @@ impl Session {
}
RequestPermissionsResponse {
permissions: intersect_permission_profiles(
permissions: intersect_permission_profiles_with_context(
requested_permissions.into(),
response.permissions.into(),
cwd,
context,
)
.into(),
scope: response.scope,
+46 -29
View File
@@ -79,6 +79,7 @@ use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSandboxPolicyContext;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::EnvironmentConfigState;
use codex_protocol::protocol::ErrorEvent;
@@ -7244,6 +7245,13 @@ fn strict_auto_review_session_scope_grants_no_permissions() {
..RequestPermissionProfile::default()
};
let cwd = PathUri::parse("file:///tmp").expect("test cwd should be valid");
let context = FileSystemSandboxPolicyContext {
cwd: &cwd,
workspace_roots: &[],
user_home_dir: None,
temporary_directories: None,
};
let response = Session::normalize_request_permissions_response(
requested_permissions.clone(),
codex_protocol::request_permissions::RequestPermissionsResponse {
@@ -7251,7 +7259,7 @@ fn strict_auto_review_session_scope_grants_no_permissions() {
scope: PermissionGrantScope::Session,
strict_auto_review: true,
},
std::path::Path::new("/tmp"),
&context,
);
assert_eq!(
@@ -7355,7 +7363,7 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests()
}
#[tokio::test]
async fn request_permissions_tool_resolves_relative_paths_against_selected_environment() {
async fn request_permissions_tool_resolves_legacy_paths_against_selected_environment() {
let (session, mut turn_context, rx) = make_session_and_context_with_rx().await;
*session.active_turn.lock().await = Some(ActiveTurn::default());
let environment_cwd = {
@@ -7363,6 +7371,7 @@ async fn request_permissions_tool_resolves_relative_paths_against_selected_envir
let legacy_cwd = turn_context.cwd.clone();
legacy_cwd.join("request-permissions-environment")
};
let environment_home = environment_cwd.join("home");
std::fs::create_dir_all(environment_cwd.as_path()).expect("create environment cwd");
let turn_context_mut = Arc::get_mut(&mut turn_context).expect("single thread settings ref");
Arc::make_mut(&mut turn_context_mut.config)
@@ -7382,8 +7391,9 @@ async fn request_permissions_tool_resolves_relative_paths_against_selected_envir
.expect("primary environment")
.clone();
let current_environment_config = current_environment.config().clone();
turn_context_mut.environments.environments[0] =
TurnEnvironmentState::Ready(TurnEnvironment::new(
let environment = TurnEnvironment {
user_home_dir: Some(PathUri::from_abs_path(&environment_home)),
..TurnEnvironment::new(
TurnEnvironmentSelection {
environment_id: "remote".to_string(),
cwd: PathUri::from_abs_path(&environment_cwd),
@@ -7393,7 +7403,9 @@ async fn request_permissions_tool_resolves_relative_paths_against_selected_envir
current_environment.config_origin,
current_environment.environment,
current_environment.shell,
));
)
};
turn_context_mut.environments.environments[0] = TurnEnvironmentState::Ready(environment);
let call_id = "call-1".to_string();
let handler = RequestPermissionsHandler;
@@ -7422,13 +7434,8 @@ async fn request_permissions_tool_resolves_relative_paths_against_selected_envir
"reason": "need write",
"permissions": {
"file_system": {
"entries": [{
"path": {
"type": "path",
"path": "relative.txt",
},
"access": "write",
}],
"read": null,
"write": ["relative.txt", "~/home-relative.txt"],
},
},
})
@@ -7447,16 +7454,13 @@ async fn request_permissions_tool_resolves_relative_paths_against_selected_envir
panic!("expected request_permissions event");
};
let expected_permissions = RequestPermissionProfile {
file_system: Some(FileSystemPermissions {
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: environment_cwd.join("relative.txt").into(),
},
access: FileSystemAccessMode::Write,
missing_path_behavior: None,
}],
glob_scan_max_depth: None,
}),
file_system: Some(FileSystemPermissions::from_read_write_roots(
/*read*/ None,
Some(vec![
environment_cwd.join("relative.txt"),
environment_home.join("home-relative.txt"),
]),
)),
..Default::default()
};
assert_eq!(request.environment_id.as_deref(), Some("remote"));
@@ -7479,9 +7483,22 @@ async fn request_permissions_tool_resolves_relative_paths_against_selected_envir
.expect("request_permissions handler should succeed");
}
#[test_case("missing", "unknown turn environment id `missing`"; "unknown environment")]
#[test_case("local", "permission path cannot be represented losslessly"; "lossy path")]
#[tokio::test]
async fn request_permissions_tool_rejects_unknown_environment_id() {
let (session, turn_context) = make_session_and_context().await;
async fn request_permissions_tool_rejects_invalid_requests(
environment_id: &str,
expected_error: &str,
) {
let (session, mut turn_context) = make_session_and_context().await;
Arc::make_mut(&mut turn_context.config)
.permissions
.approval_policy = codex_config::Constrained::allow_any(AskForApproval::Never);
let TurnEnvironmentState::Ready(environment) = &mut turn_context.environments.environments[0]
else {
panic!("turn environment should be ready");
};
environment.selection.cwd = PathUri::parse("file:///workspace/%FF").expect("non-UTF8 cwd");
let turn_context = Arc::new(turn_context);
let step_context = StepContext::for_test(Arc::clone(&turn_context));
let result = RequestPermissionsHandler
@@ -7496,10 +7513,10 @@ async fn request_permissions_tool_rejects_unknown_environment_id() {
source: ToolCallSource::Direct,
payload: ToolPayload::Function {
arguments: json!({
"environment_id": "missing",
"environment_id": environment_id,
"permissions": {
"network": {
"enabled": true,
"file_system": {
"write": ["relative"],
},
},
})
@@ -7509,9 +7526,9 @@ async fn request_permissions_tool_rejects_unknown_environment_id() {
.await;
let Err(FunctionCallError::RespondToModel(output)) = result else {
panic!("expected unknown environment id to be rejected");
panic!("expected invalid request to be rejected");
};
assert_eq!(output, "unknown turn environment id `missing`");
assert_eq!(output, expected_error);
}
#[tokio::test]