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

+37 -222
View File
@@ -119,7 +119,6 @@ use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequest
use codex_protocol::request_permissions::RequestPermissionsResponse as CoreRequestPermissionsResponse; use codex_protocol::request_permissions::RequestPermissionsResponse as CoreRequestPermissionsResponse;
use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse; use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse;
use codex_sandboxing::policy_transforms::intersect_permission_profiles;
use codex_shell_command::parse_command::shlex_join; use codex_shell_command::parse_command::shlex_join;
use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::LegacyAppPathString;
@@ -930,8 +929,7 @@ pub(crate) async fn apply_bespoke_event_handling(
let permission_guard = thread_watch_manager let permission_guard = thread_watch_manager
.note_permission_requested(&conversation_id.to_string()) .note_permission_requested(&conversation_id.to_string())
.await; .await;
let requested_permissions = request.permissions.clone(); let request_cwd = match request.cwd {
let request_cwd = match request.cwd.clone() {
Some(cwd) => cwd, Some(cwd) => cwd,
None => conversation.config_snapshot().await.cwd().clone(), None => conversation.config_snapshot().await.cwd().clone(),
}; };
@@ -941,7 +939,7 @@ pub(crate) async fn apply_bespoke_event_handling(
item_id: request.call_id.clone(), item_id: request.call_id.clone(),
environment_id: request.environment_id.clone(), environment_id: request.environment_id.clone(),
started_at_ms: request.started_at_ms, started_at_ms: request.started_at_ms,
cwd: request_cwd.clone(), cwd: request_cwd,
reason: request.reason, reason: request.reason,
permissions: request.permissions.into(), permissions: request.permissions.into(),
}; };
@@ -952,8 +950,6 @@ pub(crate) async fn apply_bespoke_event_handling(
call_id: request.call_id, call_id: request.call_id,
conversation_id, conversation_id,
turn_id: request.turn_id, turn_id: request.turn_id,
requested_permissions,
request_cwd,
pending_request_id, pending_request_id,
outgoing, outgoing,
receiver: rx, receiver: rx,
@@ -1887,8 +1883,6 @@ async fn on_request_permissions_response(
call_id, call_id,
conversation_id, conversation_id,
turn_id, turn_id,
requested_permissions,
request_cwd,
pending_request_id, pending_request_id,
outgoing, outgoing,
receiver, receiver,
@@ -1897,11 +1891,7 @@ async fn on_request_permissions_response(
let response = receiver.await; let response = receiver.await;
resolve_server_request_on_thread_listener(&thread_state, pending_request_id.clone()).await; resolve_server_request_on_thread_listener(&thread_state, pending_request_id.clone()).await;
drop(request_permissions_guard); drop(request_permissions_guard);
let response = match request_permissions_response_from_client_result( let response = match request_permissions_response_from_client_result(response) {
requested_permissions,
response,
request_cwd.as_path(),
) {
Ok(Some(response)) => response, Ok(Some(response)) => response,
Ok(None) => return, Ok(None) => return,
// TODO(anp): Remove this native-path localization error path once core permission paths // TODO(anp): Remove this native-path localization error path once core permission paths
@@ -1944,8 +1934,6 @@ struct PendingRequestPermissionsResponse {
call_id: String, call_id: String,
conversation_id: ThreadId, conversation_id: ThreadId,
turn_id: String, turn_id: String,
requested_permissions: CoreRequestPermissionProfile,
request_cwd: AbsolutePathBuf,
pending_request_id: RequestId, pending_request_id: RequestId,
outgoing: ThreadScopedOutgoingMessageSender, outgoing: ThreadScopedOutgoingMessageSender,
receiver: oneshot::Receiver<ClientRequestResult>, receiver: oneshot::Receiver<ClientRequestResult>,
@@ -1953,9 +1941,7 @@ struct PendingRequestPermissionsResponse {
} }
fn request_permissions_response_from_client_result( fn request_permissions_response_from_client_result(
requested_permissions: CoreRequestPermissionProfile,
response: std::result::Result<ClientRequestResult, oneshot::error::RecvError>, response: std::result::Result<ClientRequestResult, oneshot::error::RecvError>,
cwd: &std::path::Path,
) -> std::io::Result<Option<CoreRequestPermissionsResponse>> { ) -> std::io::Result<Option<CoreRequestPermissionsResponse>> {
let value = match response { let value = match response {
Ok(Ok(value)) => value, Ok(Ok(value)) => value,
@@ -2002,13 +1988,9 @@ fn request_permissions_response_from_client_result(
})); }));
} }
let granted_permissions: CoreAdditionalPermissionProfile = response.permissions.try_into()?; let granted_permissions: CoreAdditionalPermissionProfile = response.permissions.try_into()?;
let permissions = if granted_permissions.is_empty() { // Core intersects with the request using the originating environment's policy context.
CoreRequestPermissionProfile::default()
} else {
intersect_permission_profiles(requested_permissions.into(), granted_permissions, cwd).into()
};
Ok(Some(CoreRequestPermissionsResponse { Ok(Some(CoreRequestPermissionsResponse {
permissions, permissions: CoreRequestPermissionProfile::from(granted_permissions),
scope: response.scope.to_core(), scope: response.scope.to_core(),
strict_auto_review, strict_auto_review,
})) }))
@@ -2229,10 +2211,6 @@ mod tests {
use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions;
use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions;
use codex_protocol::models::PermissionProfile; use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::PlanItemArg;
use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::StepStatus;
use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::AgentMessageEvent;
@@ -3058,18 +3036,14 @@ mod tests {
data: Some(serde_json::json!({ "reason": "turnTransition" })), data: Some(serde_json::json!({ "reason": "turnTransition" })),
}; };
let response = request_permissions_response_from_client_result( let response = request_permissions_response_from_client_result(Ok(Err(error)))
CoreRequestPermissionProfile::default(), .expect("paths should localize");
Ok(Err(error)),
std::env::current_dir().expect("current dir").as_path(),
)
.expect("paths should localize");
assert_eq!(response, None); assert_eq!(response, None);
} }
#[test] #[test]
fn request_permissions_response_accepts_partial_network_and_file_system_grants() { fn request_permissions_response_deserializes_network_and_file_system_grants() {
let input_path = if cfg!(target_os = "windows") { let input_path = if cfg!(target_os = "windows") {
r"C:\tmp\input" r"C:\tmp\input"
} else { } else {
@@ -3080,23 +3054,14 @@ mod tests {
} else { } else {
"/tmp/output" "/tmp/output"
}; };
let ignored_path = if cfg!(target_os = "windows") { let extra_path = if cfg!(target_os = "windows") {
r"C:\tmp\ignored" r"C:\tmp\extra"
} else { } else {
"/tmp/ignored" "/tmp/extra"
}; };
let absolute_path = |path: &str| { let absolute_path = |path: &str| {
AbsolutePathBuf::try_from(std::path::PathBuf::from(path)).expect("absolute path") AbsolutePathBuf::try_from(std::path::PathBuf::from(path)).expect("absolute path")
}; };
let requested_permissions = CoreRequestPermissionProfile {
network: Some(CoreNetworkPermissions {
enabled: Some(true),
}),
file_system: Some(CoreFileSystemPermissions::from_read_write_roots(
Some(vec![absolute_path(input_path)]),
Some(vec![absolute_path(output_path)]),
)),
};
let cases = vec![ let cases = vec![
( (
serde_json::json!({}), serde_json::json!({}),
@@ -3133,7 +3098,7 @@ mod tests {
serde_json::json!({ serde_json::json!({
"fileSystem": { "fileSystem": {
"read": [input_path], "read": [input_path],
"write": [output_path, ignored_path], "write": [output_path, extra_path],
}, },
"macos": { "macos": {
"calendar": true, "calendar": true,
@@ -3142,24 +3107,20 @@ mod tests {
CoreRequestPermissionProfile { CoreRequestPermissionProfile {
file_system: Some(CoreFileSystemPermissions::from_read_write_roots( file_system: Some(CoreFileSystemPermissions::from_read_write_roots(
Some(vec![absolute_path(input_path)]), Some(vec![absolute_path(input_path)]),
Some(vec![absolute_path(output_path)]), Some(vec![absolute_path(output_path), absolute_path(extra_path)]),
)), )),
..CoreRequestPermissionProfile::default() ..CoreRequestPermissionProfile::default()
}, },
), ),
]; ];
let cwd = std::env::current_dir().expect("current dir");
for (granted_permissions, expected_permissions) in cases { for (granted_permissions, expected_permissions) in cases {
let response = request_permissions_response_from_client_result( let response =
requested_permissions.clone(), request_permissions_response_from_client_result(Ok(Ok(serde_json::json!({
Ok(Ok(serde_json::json!({
"permissions": granted_permissions, "permissions": granted_permissions,
}))), }))))
cwd.as_path(), .expect("paths should localize")
) .expect("response should be accepted");
.expect("paths should localize")
.expect("response should be accepted");
assert_eq!( assert_eq!(
response, response,
@@ -3174,14 +3135,10 @@ mod tests {
#[test] #[test]
fn request_permissions_response_preserves_session_scope() { fn request_permissions_response_preserves_session_scope() {
let response = request_permissions_response_from_client_result( let response = request_permissions_response_from_client_result(Ok(Ok(serde_json::json!({
CoreRequestPermissionProfile::default(), "scope": "session",
Ok(Ok(serde_json::json!({ "permissions": {},
"scope": "session", }))))
"permissions": {},
}))),
std::env::current_dir().expect("current dir").as_path(),
)
.expect("paths should localize") .expect("paths should localize")
.expect("response should be accepted"); .expect("response should be accepted");
@@ -3197,19 +3154,15 @@ mod tests {
#[test] #[test]
fn request_permissions_response_rejects_session_scoped_strict_auto_review() { fn request_permissions_response_rejects_session_scoped_strict_auto_review() {
let response = request_permissions_response_from_client_result( let response = request_permissions_response_from_client_result(Ok(Ok(serde_json::json!({
CoreRequestPermissionProfile::default(), "scope": "session",
Ok(Ok(serde_json::json!({ "strictAutoReview": true,
"scope": "session", "permissions": {
"strictAutoReview": true, "network": {
"permissions": { "enabled": true,
"network": {
"enabled": true,
},
}, },
}))), },
std::env::current_dir().expect("current dir").as_path(), }))))
)
.expect("paths should localize") .expect("paths should localize")
.expect("response should be accepted"); .expect("response should be accepted");
@@ -3225,23 +3178,14 @@ mod tests {
#[test] #[test]
fn request_permissions_response_preserves_turn_scoped_strict_auto_review() { fn request_permissions_response_preserves_turn_scoped_strict_auto_review() {
let response = request_permissions_response_from_client_result( let response = request_permissions_response_from_client_result(Ok(Ok(serde_json::json!({
CoreRequestPermissionProfile { "strictAutoReview": true,
network: Some(codex_protocol::models::NetworkPermissions { "permissions": {
enabled: Some(true), "network": {
}), "enabled": true,
..Default::default()
},
Ok(Ok(serde_json::json!({
"strictAutoReview": true,
"permissions": {
"network": {
"enabled": true,
},
}, },
}))), },
std::env::current_dir().expect("current dir").as_path(), }))))
)
.expect("paths should localize") .expect("paths should localize")
.expect("response should be accepted"); .expect("response should be accepted");
@@ -3249,135 +3193,6 @@ mod tests {
assert!(response.strict_auto_review); assert!(response.strict_auto_review);
} }
#[test]
fn request_permissions_response_accepts_explicit_child_grant_for_requested_cwd_scope() {
let temp_dir = TempDir::new().expect("temp dir");
let cwd = AbsolutePathBuf::from_absolute_path(temp_dir.path()).expect("absolute cwd");
let child = cwd.join("child");
let requested_permissions = CoreRequestPermissionProfile {
file_system: Some(CoreFileSystemPermissions {
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
missing_path_behavior: None,
}],
glob_scan_max_depth: None,
}),
..Default::default()
};
let response = request_permissions_response_from_client_result(
requested_permissions,
Ok(Ok(serde_json::json!({
"permissions": {
"fileSystem": {
"write": [child],
},
},
}))),
cwd.as_path(),
)
.expect("paths should localize")
.expect("response should be accepted");
assert_eq!(
response.permissions,
CoreRequestPermissionProfile {
file_system: Some(CoreFileSystemPermissions::from_read_write_roots(
/*read*/ None,
Some(vec![child]),
)),
..Default::default()
}
);
}
#[test]
fn request_permissions_response_rejects_child_grant_outside_requested_cwd_scope() {
let temp_dir = TempDir::new().expect("temp dir");
let request_cwd = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("request-cwd"))
.expect("absolute request cwd");
let later_cwd = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("later-cwd"))
.expect("absolute later cwd");
let later_child = later_cwd.join("child");
let requested_permissions = CoreRequestPermissionProfile {
file_system: Some(CoreFileSystemPermissions {
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
missing_path_behavior: None,
}],
glob_scan_max_depth: None,
}),
..Default::default()
};
let response = request_permissions_response_from_client_result(
requested_permissions,
Ok(Ok(serde_json::json!({
"permissions": {
"fileSystem": {
"write": [later_child],
},
},
}))),
request_cwd.as_path(),
)
.expect("paths should localize")
.expect("response should be accepted");
assert_eq!(
response.permissions,
CoreRequestPermissionProfile::default()
);
}
#[test]
fn request_permissions_response_ignores_broader_cwd_grant_for_requested_child_path() {
let temp_dir = TempDir::new().expect("temp dir");
let cwd = AbsolutePathBuf::from_absolute_path(temp_dir.path()).expect("absolute cwd");
let child = cwd.join("child");
let requested_permissions = CoreRequestPermissionProfile {
file_system: Some(CoreFileSystemPermissions::from_read_write_roots(
/*read*/ None,
Some(vec![child]),
)),
..Default::default()
};
let response = request_permissions_response_from_client_result(
requested_permissions,
Ok(Ok(serde_json::json!({
"permissions": {
"fileSystem": {
"entries": [{
"path": {
"type": "special",
"value": {
"kind": "project_roots",
"subpath": null
}
},
"access": "write"
}],
},
},
}))),
cwd.as_path(),
)
.expect("paths should localize")
.expect("response should be accepted");
assert_eq!(
response.permissions,
CoreRequestPermissionProfile::default()
);
}
#[tokio::test] #[tokio::test]
async fn test_handle_error_records_message() -> Result<()> { async fn test_handle_error_records_message() -> Result<()> {
let conversation_id = ThreadId::new(); let conversation_id = ThreadId::new();
@@ -2,7 +2,6 @@ use anyhow::Result;
use app_test_support::MockResponsesConfig; use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer; use app_test_support::TestAppServer;
use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::create_request_permissions_sse_response; use app_test_support::create_request_permissions_sse_response;
use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCMessage;
@@ -16,7 +15,15 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput; use codex_app_server_protocol::UserInput as V2UserInput;
use codex_features::Feature; use codex_features::Feature;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionProfile;
use codex_protocol::request_permissions::RequestPermissionsResponse;
use codex_utils_path_uri::LegacyAppPathString;
use core_test_support::responses;
use core_test_support::skip_if_wine_exec; use core_test_support::skip_if_wine_exec;
use pretty_assertions::assert_eq;
use serde_json::json;
use tokio::time::timeout; use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
@@ -30,11 +37,31 @@ async fn request_permissions_round_trip() -> Result<()> {
); );
let codex_home = tempfile::TempDir::new()?; let codex_home = tempfile::TempDir::new()?;
let project_root_entry = json!({
"path": {
"type": "special",
"value": {"kind": "project_roots", "subpath": "output"}
},
"access": "write"
});
let responses = vec![ let responses = vec![
create_request_permissions_sse_response("call1")?, create_request_permissions_sse_response("call1")?,
responses::sse(vec![
responses::ev_response_created("resp-2"),
responses::ev_function_call(
"call2",
"request_permissions",
&json!({
"permissions": {"file_system": {"entries": [project_root_entry]}}
})
.to_string(),
),
responses::ev_completed("resp-2"),
]),
create_final_assistant_message_sse_response("done")?, create_final_assistant_message_sse_response("done")?,
]; ];
let server = create_mock_responses_server_sequence(responses).await; let server = responses::start_mock_server().await;
let mock = responses::mount_sse_sequence(&server, responses).await;
MockResponsesConfig::new(&server.uri()) MockResponsesConfig::new(&server.uri())
.with_approval_policy("on-request") .with_approval_policy("on-request")
.enable_feature(Feature::RequestPermissionsTool) .enable_feature(Feature::RequestPermissionsTool)
@@ -44,6 +71,10 @@ async fn request_permissions_round_trip() -> Result<()> {
.with_codex_home(codex_home.path()) .with_codex_home(codex_home.path())
.build_initialized() .build_initialized()
.await?; .await?;
let cwd = mcp.auto_env()?.selection().cwd.clone();
let workspace_root = cwd.parent().expect("test cwd has a parent");
let mut environment = mcp.auto_env_params()?;
environment.runtime_workspace_roots = Some(vec![workspace_root.clone().into()]);
let ThreadStartResponse { thread, .. } = mcp let ThreadStartResponse { thread, .. } = mcp
.start_thread(ThreadStartParams { .start_thread(ThreadStartParams {
@@ -58,6 +89,7 @@ async fn request_permissions_round_trip() -> Result<()> {
params: TurnStartParams { params: TurnStartParams {
thread_id: thread.id.clone(), thread_id: thread.id.clone(),
client_user_message_id: None, client_user_message_id: None,
environments: Some(vec![environment]),
input: vec![V2UserInput::Text { input: vec![V2UserInput::Text {
text: "pick a directory".to_string(), text: "pick a directory".to_string(),
text_elements: Vec::new(), text_elements: Vec::new(),
@@ -108,8 +140,6 @@ async fn request_permissions_round_trip() -> Result<()> {
}, },
]) ])
); );
let resolved_request_id = request_id.clone();
mcp.send_response( mcp.send_response(
request_id, request_id,
serde_json::to_value(PermissionsRequestApprovalResponse { serde_json::to_value(PermissionsRequestApprovalResponse {
@@ -128,6 +158,28 @@ async fn request_permissions_round_trip() -> Result<()> {
) )
.await?; .await?;
let server_req = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_request_message(),
)
.await??;
let ServerRequest::PermissionsRequestApproval { request_id, params } = server_req else {
panic!("expected PermissionsRequestApproval request, got: {server_req:?}");
};
assert_eq!(params.item_id, "call2");
let resolved_request_id = request_id.clone();
let outside_request: LegacyAppPathString = cwd.join("output")?.into();
mcp.send_response(
request_id,
json!({
"permissions": {"fileSystem": {"entries": [
project_root_entry,
{"path": {"type": "path", "path": outside_request}, "access": "write"}
]}}
}),
)
.await?;
let mut saw_resolved = false; let mut saw_resolved = false;
loop { loop {
let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??;
@@ -143,8 +195,9 @@ async fn request_permissions_round_trip() -> Result<()> {
.expect("serverRequest/resolved params"), .expect("serverRequest/resolved params"),
)?; )?;
assert_eq!(resolved.thread_id, thread.id); assert_eq!(resolved.thread_id, thread.id);
assert_eq!(resolved.request_id, resolved_request_id); if resolved.request_id == resolved_request_id {
saw_resolved = true; saw_resolved = true;
}
} }
"turn/completed" => { "turn/completed" => {
assert!(saw_resolved, "serverRequest/resolved should arrive first"); assert!(saw_resolved, "serverRequest/resolved should arrive first");
@@ -154,5 +207,25 @@ async fn request_permissions_round_trip() -> Result<()> {
} }
} }
let (output, _) = mock.requests()[2]
.function_call_output_content_and_success("call2")
.expect("permission tool output");
let response: RequestPermissionsResponse =
serde_json::from_str(&output.expect("permission response text"))?;
assert_eq!(
response,
RequestPermissionsResponse {
permissions: RequestPermissionProfile {
file_system: Some(FileSystemPermissions::from_read_write_path_uris(
/*read*/ None,
Some(vec![workspace_root.join("output")?]),
)),
..Default::default()
},
scope: CorePermissionGrantScope::Turn,
strict_auto_review: false,
}
);
Ok(()) Ok(())
} }
+53 -34
View File
@@ -2,7 +2,6 @@ use std::borrow::Cow;
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::HashSet; use std::collections::HashSet;
use std::fmt::Debug; use std::fmt::Debug;
use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::AtomicU64; 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::ModelInfo;
use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelPreset;
use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSandboxPolicyContext;
use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::FileChange; use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::HasLegacyEvent; use codex_protocol::protocol::HasLegacyEvent;
@@ -156,7 +156,7 @@ use codex_rollout::state_db;
use codex_rollout_trace::AgentResultTracePayload; use codex_rollout_trace::AgentResultTracePayload;
use codex_rollout_trace::ThreadStartedTraceMetadata; use codex_rollout_trace::ThreadStartedTraceMetadata;
use codex_rollout_trace::ThreadTraceContext; 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_shell_command::parse_command::parse_command;
use codex_terminal_detection::user_agent; use codex_terminal_detection::user_agent;
use codex_thread_store::CreateThreadParams; use codex_thread_store::CreateThreadParams;
@@ -2757,12 +2757,27 @@ impl Session {
step_context: &Arc<StepContext>, step_context: &Arc<StepContext>,
call_id: String, call_id: String,
args: RequestPermissionsArgs, args: RequestPermissionsArgs,
environment: TurnEnvironmentSelection, environment_selection: TurnEnvironmentSelection,
cancellation_token: CancellationToken, cancellation_token: CancellationToken,
) -> Option<RequestPermissionsResponse> { ) -> Option<RequestPermissionsResponse> {
let turn_context = &step_context.turn; let turn_context = &step_context.turn;
let approval_policy = step_context.settings.approval_policy(); let approval_policy = step_context.settings.approval_policy();
let approvals_reviewer = step_context.settings.approvals_reviewer(); 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 { match approval_policy {
AskForApproval::Never => { AskForApproval::Never => {
return Some(RequestPermissionsResponse { return Some(RequestPermissionsResponse {
@@ -2786,19 +2801,14 @@ impl Session {
} }
let requested_permissions = args.permissions; let requested_permissions = args.permissions;
// TODO(anp): Migrate request_permissions to support paths from foreign environments. let sandbox_context = environment.sandbox_context(/*additional_permissions*/ None);
let Ok(native_environment_cwd) = environment.cwd.to_abs_path() else { let Some(context) = sandbox_context.policy_context() else {
warn!(
cwd = %environment.cwd,
"request_permissions requires a cwd native to the Codex host"
);
return Some(RequestPermissionsResponse { return Some(RequestPermissionsResponse {
permissions: RequestPermissionProfile::default(), permissions: RequestPermissionProfile::default(),
scope: PermissionGrantScope::Turn, scope: PermissionGrantScope::Turn,
strict_auto_review: false, strict_auto_review: false,
}); });
}; };
if crate::guardian::routes_approval_policy_to_guardian(approval_policy, approvals_reviewer) if crate::guardian::routes_approval_policy_to_guardian(approval_policy, approvals_reviewer)
{ {
let originating_turn_state = { let originating_turn_state = {
@@ -2868,17 +2878,30 @@ impl Session {
let response = Self::normalize_request_permissions_response( let response = Self::normalize_request_permissions_response(
requested_permissions, requested_permissions,
response, response,
native_environment_cwd.as_path(), &context,
); );
self.record_granted_request_permissions_for_turn( self.record_granted_request_permissions_for_turn(
&response, &response,
&environment.environment_id, &environment.selection.environment_id,
originating_turn_state.as_ref(), originating_turn_state.as_ref(),
) )
.await; .await;
return Some(response); 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 _elicitation = self.services.elicitations.register();
let (tx_response, rx_response) = oneshot::channel(); let (tx_response, rx_response) = oneshot::channel();
let prev_entry = { let prev_entry = {
@@ -2905,11 +2928,11 @@ impl Session {
let event = EventMsg::RequestPermissions(RequestPermissionsEvent { let event = EventMsg::RequestPermissions(RequestPermissionsEvent {
call_id: call_id.clone(), call_id: call_id.clone(),
turn_id: turn_context.sub_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(), started_at_ms: now_unix_timestamp_ms(),
reason: args.reason, reason: args.reason,
permissions: requested_permissions, permissions: requested_permissions,
cwd: Some(native_environment_cwd), cwd: Some(native_cwd),
}); });
self.send_event(turn_context.as_ref(), event).await; self.send_event(turn_context.as_ref(), event).await;
tokio::select! { tokio::select! {
@@ -3020,29 +3043,25 @@ impl Session {
}; };
match entry { match entry {
Some(entry) => { Some(entry) => {
// TODO(anp): Migrate request_permissions to support paths from foreign environments. let sandbox_context = entry
let response = match entry.environment.cwd.to_abs_path() { .environment
Ok(native_environment_cwd) => Self::normalize_request_permissions_response( .sandbox_context(/*additional_permissions*/ None);
let response = if let Some(context) = sandbox_context.policy_context() {
Self::normalize_request_permissions_response(
entry.requested_permissions, entry.requested_permissions,
response, response,
native_environment_cwd.as_path(), &context,
), )
Err(err) => { } else {
warn!( RequestPermissionsResponse {
cwd = %entry.environment.cwd, permissions: RequestPermissionProfile::default(),
%err, scope: PermissionGrantScope::Turn,
"request_permissions requires a cwd native to the Codex host" strict_auto_review: false,
);
RequestPermissionsResponse {
permissions: RequestPermissionProfile::default(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
}
} }
}; };
self.record_granted_request_permissions_for_turn( self.record_granted_request_permissions_for_turn(
&response, &response,
&entry.environment.environment_id, &entry.environment.selection.environment_id,
originating_turn_state.as_ref(), originating_turn_state.as_ref(),
) )
.await; .await;
@@ -3057,7 +3076,7 @@ impl Session {
fn normalize_request_permissions_response( fn normalize_request_permissions_response(
requested_permissions: RequestPermissionProfile, requested_permissions: RequestPermissionProfile,
response: RequestPermissionsResponse, response: RequestPermissionsResponse,
cwd: &Path, context: &FileSystemSandboxPolicyContext<'_>,
) -> RequestPermissionsResponse { ) -> RequestPermissionsResponse {
if response.strict_auto_review && matches!(response.scope, PermissionGrantScope::Session) { if response.strict_auto_review && matches!(response.scope, PermissionGrantScope::Session) {
return RequestPermissionsResponse { return RequestPermissionsResponse {
@@ -3072,10 +3091,10 @@ impl Session {
} }
RequestPermissionsResponse { RequestPermissionsResponse {
permissions: intersect_permission_profiles( permissions: intersect_permission_profiles_with_context(
requested_permissions.into(), requested_permissions.into(),
response.permissions.into(), response.permissions.into(),
cwd, context,
) )
.into(), .into(),
scope: response.scope, 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::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSandboxPolicyContext;
use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::EnvironmentConfigState; use codex_protocol::protocol::EnvironmentConfigState;
use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::ErrorEvent;
@@ -7244,6 +7245,13 @@ fn strict_auto_review_session_scope_grants_no_permissions() {
..RequestPermissionProfile::default() ..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( let response = Session::normalize_request_permissions_response(
requested_permissions.clone(), requested_permissions.clone(),
codex_protocol::request_permissions::RequestPermissionsResponse { codex_protocol::request_permissions::RequestPermissionsResponse {
@@ -7251,7 +7259,7 @@ fn strict_auto_review_session_scope_grants_no_permissions() {
scope: PermissionGrantScope::Session, scope: PermissionGrantScope::Session,
strict_auto_review: true, strict_auto_review: true,
}, },
std::path::Path::new("/tmp"), &context,
); );
assert_eq!( assert_eq!(
@@ -7355,7 +7363,7 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests()
} }
#[tokio::test] #[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; let (session, mut turn_context, rx) = make_session_and_context_with_rx().await;
*session.active_turn.lock().await = Some(ActiveTurn::default()); *session.active_turn.lock().await = Some(ActiveTurn::default());
let environment_cwd = { 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(); let legacy_cwd = turn_context.cwd.clone();
legacy_cwd.join("request-permissions-environment") 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"); 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"); let turn_context_mut = Arc::get_mut(&mut turn_context).expect("single thread settings ref");
Arc::make_mut(&mut turn_context_mut.config) 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") .expect("primary environment")
.clone(); .clone();
let current_environment_config = current_environment.config().clone(); let current_environment_config = current_environment.config().clone();
turn_context_mut.environments.environments[0] = let environment = TurnEnvironment {
TurnEnvironmentState::Ready(TurnEnvironment::new( user_home_dir: Some(PathUri::from_abs_path(&environment_home)),
..TurnEnvironment::new(
TurnEnvironmentSelection { TurnEnvironmentSelection {
environment_id: "remote".to_string(), environment_id: "remote".to_string(),
cwd: PathUri::from_abs_path(&environment_cwd), 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.config_origin,
current_environment.environment, current_environment.environment,
current_environment.shell, current_environment.shell,
)); )
};
turn_context_mut.environments.environments[0] = TurnEnvironmentState::Ready(environment);
let call_id = "call-1".to_string(); let call_id = "call-1".to_string();
let handler = RequestPermissionsHandler; let handler = RequestPermissionsHandler;
@@ -7422,13 +7434,8 @@ async fn request_permissions_tool_resolves_relative_paths_against_selected_envir
"reason": "need write", "reason": "need write",
"permissions": { "permissions": {
"file_system": { "file_system": {
"entries": [{ "read": null,
"path": { "write": ["relative.txt", "~/home-relative.txt"],
"type": "path",
"path": "relative.txt",
},
"access": "write",
}],
}, },
}, },
}) })
@@ -7447,16 +7454,13 @@ async fn request_permissions_tool_resolves_relative_paths_against_selected_envir
panic!("expected request_permissions event"); panic!("expected request_permissions event");
}; };
let expected_permissions = RequestPermissionProfile { let expected_permissions = RequestPermissionProfile {
file_system: Some(FileSystemPermissions { file_system: Some(FileSystemPermissions::from_read_write_roots(
entries: vec![FileSystemSandboxEntry { /*read*/ None,
path: FileSystemPath::Path { Some(vec![
path: environment_cwd.join("relative.txt").into(), environment_cwd.join("relative.txt"),
}, environment_home.join("home-relative.txt"),
access: FileSystemAccessMode::Write, ]),
missing_path_behavior: None, )),
}],
glob_scan_max_depth: None,
}),
..Default::default() ..Default::default()
}; };
assert_eq!(request.environment_id.as_deref(), Some("remote")); 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"); .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] #[tokio::test]
async fn request_permissions_tool_rejects_unknown_environment_id() { async fn request_permissions_tool_rejects_invalid_requests(
let (session, turn_context) = make_session_and_context().await; 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 turn_context = Arc::new(turn_context);
let step_context = StepContext::for_test(Arc::clone(&turn_context)); let step_context = StepContext::for_test(Arc::clone(&turn_context));
let result = RequestPermissionsHandler let result = RequestPermissionsHandler
@@ -7496,10 +7513,10 @@ async fn request_permissions_tool_rejects_unknown_environment_id() {
source: ToolCallSource::Direct, source: ToolCallSource::Direct,
payload: ToolPayload::Function { payload: ToolPayload::Function {
arguments: json!({ arguments: json!({
"environment_id": "missing", "environment_id": environment_id,
"permissions": { "permissions": {
"network": { "file_system": {
"enabled": true, "write": ["relative"],
}, },
}, },
}) })
@@ -7509,9 +7526,9 @@ async fn request_permissions_tool_rejects_unknown_environment_id() {
.await; .await;
let Err(FunctionCallError::RespondToModel(output)) = result else { 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] #[tokio::test]
+2 -2
View File
@@ -9,7 +9,6 @@ use tokio_util::task::AbortOnDropHandle;
use codex_diagnostics::GaugeGuard; use codex_diagnostics::GaugeGuard;
use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionProfile;
use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_permissions::RequestPermissionsResponse;
use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::request_user_input::RequestUserInputResponse;
@@ -23,6 +22,7 @@ use crate::mcp_tool_call::McpToolApprovalMetadata;
use crate::session::TurnInputQueue; use crate::session::TurnInputQueue;
use crate::session::step_context::StepContext; use crate::session::step_context::StepContext;
use crate::session::turn_context::TurnContext; use crate::session::turn_context::TurnContext;
use crate::session::turn_context::TurnEnvironment;
use crate::tasks::AnySessionTask; use crate::tasks::AnySessionTask;
use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::McpInvocation;
@@ -109,7 +109,7 @@ pub(crate) struct TurnState {
pub(crate) struct PendingRequestPermissions { pub(crate) struct PendingRequestPermissions {
pub(crate) tx_response: oneshot::Sender<RequestPermissionsResponse>, pub(crate) tx_response: oneshot::Sender<RequestPermissionsResponse>,
pub(crate) requested_permissions: RequestPermissionProfile, pub(crate) requested_permissions: RequestPermissionProfile,
pub(crate) environment: TurnEnvironmentSelection, pub(crate) environment: TurnEnvironment,
} }
impl TurnState { impl TurnState {
@@ -1,5 +1,8 @@
use codex_protocol::permissions::FileSystemSandboxPolicyContext;
use codex_protocol::request_permissions::RequestPermissionsArgs; use codex_protocol::request_permissions::RequestPermissionsArgs;
use codex_sandboxing::policy_transforms::normalize_additional_permissions; use codex_sandboxing::policy_transforms::normalize_additional_permissions_with_context;
use codex_utils_path_uri::LegacyAppPathString;
use serde_json::Value;
use crate::function_tool::FunctionCallError; use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput; use crate::tools::context::FunctionToolOutput;
@@ -7,7 +10,6 @@ use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload; use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output; use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::parse_arguments; use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::parse_arguments_with_base_path;
use crate::tools::handlers::resolve_tool_environment; use crate::tools::handlers::resolve_tool_environment;
use crate::tools::handlers::shell_spec::create_request_permissions_tool; use crate::tools::handlers::shell_spec::create_request_permissions_tool;
use crate::tools::handlers::shell_spec::request_permissions_tool_description; use crate::tools::handlers::shell_spec::request_permissions_tool_description;
@@ -75,19 +77,25 @@ impl RequestPermissionsHandler {
"request_permissions requires a primary environment".to_string(), "request_permissions requires a primary environment".to_string(),
)); ));
}; };
// TODO(anp): Migrate request_permissions parsing and permission profiles to PathUri so let sandbox_context =
// environment-native foreign paths do not require host conversion. turn_environment.sandbox_context(/*additional_permissions*/ None);
let native_cwd = turn_environment.cwd().to_abs_path().map_err(|err| { let context = sandbox_context.policy_context().ok_or_else(|| {
FunctionCallError::RespondToModel(format!( FunctionCallError::RespondToModel(
"request_permissions cwd `{}` is not native to the Codex host: {err}", "request_permissions requires an executor cwd".to_string(),
turn_environment.cwd() )
))
})?; })?;
let mut arguments: Value = parse_arguments(&arguments)?;
resolve_permission_path_strings(&mut arguments, &context)?;
let mut args: RequestPermissionsArgs = let mut args: RequestPermissionsArgs =
parse_arguments_with_base_path(&arguments, &native_cwd)?; serde_json::from_value(arguments).map_err(|err| {
args.permissions = normalize_additional_permissions(args.permissions.into()) FunctionCallError::RespondToModel(format!(
.map(codex_protocol::request_permissions::RequestPermissionProfile::from) "failed to parse function arguments: {err}"
.map_err(FunctionCallError::RespondToModel)?; ))
})?;
args.permissions =
normalize_additional_permissions_with_context(args.permissions.into(), &context)
.map(codex_protocol::request_permissions::RequestPermissionProfile::from)
.map_err(FunctionCallError::RespondToModel)?;
if args.permissions.is_empty() { if args.permissions.is_empty() {
return Err(FunctionCallError::RespondToModel( return Err(FunctionCallError::RespondToModel(
"request_permissions requires at least one permission".to_string(), "request_permissions requires at least one permission".to_string(),
@@ -123,3 +131,83 @@ impl RequestPermissionsHandler {
} }
impl CoreToolRuntime for RequestPermissionsHandler {} impl CoreToolRuntime for RequestPermissionsHandler {}
fn resolve_permission_path_strings(
arguments: &mut Value,
context: &FileSystemSandboxPolicyContext<'_>,
) -> Result<(), FunctionCallError> {
let Some(file_system) = arguments
.pointer_mut("/permissions/file_system")
.and_then(Value::as_object_mut)
else {
return Ok(());
};
let mut legacy_entries = Vec::new();
for (field, access) in [("read", "read"), ("write", "write")] {
if let Some(paths) = file_system.remove(field) {
if paths.is_null() {
continue;
}
let Value::Array(paths) = paths else {
file_system.insert(field.to_string(), paths);
continue;
};
for mut path in paths {
resolve_permission_path_string(&mut path, context)?;
legacy_entries.push(serde_json::json!({
"path": {"type": "path", "path": path},
"access": access,
}));
}
}
}
if !legacy_entries.is_empty() {
file_system
.entry("entries".to_string())
.or_insert_with(|| Value::Array(Vec::new()))
.as_array_mut()
.ok_or_else(|| {
FunctionCallError::RespondToModel(
"request_permissions file_system.entries must be an array".to_string(),
)
})?
.extend(legacy_entries);
}
if let Some(entries) = file_system.get_mut("entries").and_then(Value::as_array_mut) {
for entry in entries {
if entry.pointer("/path/type").and_then(Value::as_str) == Some("path")
&& let Some(path) = entry.pointer_mut("/path/path")
{
resolve_permission_path_string(path, context)?;
}
}
}
Ok(())
}
fn resolve_permission_path_string(
value: &mut Value,
context: &FileSystemSandboxPolicyContext<'_>,
) -> Result<(), FunctionCallError> {
let Some(path) = value.as_str() else {
return Ok(());
};
let cwd = context.cwd;
let convention = cwd.infer_path_convention().ok_or_else(|| {
FunctionCallError::RespondToModel(format!(
"request_permissions cwd `{cwd}` has no path convention"
))
})?;
let path = LegacyAppPathString::from_string(path)
.resolve_against(cwd, context.user_home_dir)
.map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?;
if path.is_opaque() || std::str::from_utf8(&path.decoded_path_bytes()).is_err() {
return Err(FunctionCallError::RespondToModel(
"permission path cannot be represented losslessly".to_string(),
));
}
let path = LegacyAppPathString::from_path_uri(&path, convention)
.map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?;
*value = Value::String(path.into_string());
Ok(())
}
+1 -1
View File
@@ -411,7 +411,7 @@ fn deny_entry_constrains_accepted_grant(
return true; return true;
} }
let Some(grant_path) = resolve_permission_path(&entry.path, context) else { let Some(grant_path) = resolve_permission_path(&entry.path, context) else {
return false; return true;
}; };
match &deny_entry.path { match &deny_entry.path {
FileSystemPath::GlobPattern { pattern } => { FileSystemPath::GlobPattern { pattern } => {
@@ -562,6 +562,47 @@ fn intersect_permission_profiles_preserves_deny_across_case_variant_grant() {
); );
} }
#[test]
fn intersect_permission_profiles_preserves_denies_for_unresolved_tmpdir() {
let cwd = PathUri::parse("file:///workspace").expect("cwd URI");
let context = FileSystemSandboxPolicyContext {
cwd: &cwd,
workspace_roots: std::slice::from_ref(&cwd),
user_home_dir: None,
temporary_directories: None,
};
let write = FileSystemSandboxEntry::new(
FileSystemPath::Special {
value: FileSystemSpecialPath::Tmpdir,
},
FileSystemAccessMode::Write,
);
let deny = FileSystemSandboxEntry::new(
PathUri::parse("file:///tmp/private").expect("deny").into(),
FileSystemAccessMode::Deny,
);
let profile = |entries| PermissionProfile {
file_system: Some(FileSystemPermissions {
entries,
glob_scan_max_depth: None,
}),
..Default::default()
};
let grant = profile(vec![write.clone()]);
let grant_with_deny = profile(vec![write, deny]);
for (requested, granted) in [
(grant_with_deny.clone(), grant.clone()),
(grant, grant_with_deny.clone()),
(grant_with_deny.clone(), grant_with_deny.clone()),
] {
assert_eq!(
intersect_permission_profiles_with_context(requested, granted, &context),
grant_with_deny
);
}
}
#[test] #[test]
fn intersect_permission_profiles_preserves_rooted_first_segment_glob_deny() { fn intersect_permission_profiles_preserves_rooted_first_segment_glob_deny() {
use FileSystemAccessMode::Deny; use FileSystemAccessMode::Deny;
@@ -1,5 +1,6 @@
use crate::PathConvention; use crate::PathConvention;
use crate::PathUri; use crate::PathUri;
use crate::PathUriParseError;
use crate::is_windows_separator_byte; use crate::is_windows_separator_byte;
use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema; use schemars::JsonSchema;
@@ -93,6 +94,54 @@ impl LegacyAppPathString {
}) })
} }
/// Resolves this raw API path spelling against an executor cwd.
///
/// Relative paths use the cwd's inferred convention. Home-relative paths
/// use the supplied executor home, and clearly foreign absolute paths are
/// rejected rather than reinterpreted as relative path text.
pub fn resolve_against(
&self,
cwd: &PathUri,
user_home_dir: Option<&PathUri>,
) -> Result<PathUri, LegacyAppPathStringError> {
let convention = cwd.infer_path_convention().ok_or_else(|| {
LegacyAppPathStringError::MissingBaseConvention {
cwd: cwd.to_string(),
}
})?;
let is_windows = convention == PathConvention::Windows;
let path = self.as_str();
let home_relative = path
.strip_prefix("~/")
.or_else(|| (path == "~").then_some(""))
.or_else(|| is_windows.then(|| path.strip_prefix(r"~\")).flatten());
if let Some(suffix) = home_relative {
let home =
user_home_dir.ok_or_else(|| LegacyAppPathStringError::MissingHomeDirectory {
path: path.to_string(),
})?;
return Ok(home.join(suffix.trim_start_matches(|separator| {
separator == '/' || is_windows && separator == '\\'
}))?);
}
if is_windows && (path.starts_with("//") || path.starts_with(r"\\")) {
return self.to_path_uri(PathConvention::Windows);
}
match self.infer_absolute_path_convention() {
Some(path_convention) if path_convention == convention => self.to_path_uri(convention),
Some(PathConvention::Posix) if is_windows => Ok(cwd.join(path)?),
Some(path_convention) => Err(LegacyAppPathStringError::MismatchedConvention {
path: path.to_string(),
path_convention,
cwd: cwd.to_string(),
convention,
}),
None => Ok(cwd.join(path)?),
}
}
/// Parses this API string as an absolute path using the convention inferred from its spelling. /// Parses this API string as an absolute path using the convention inferred from its spelling.
pub fn to_inferred_path_uri(&self) -> Option<PathUri> { pub fn to_inferred_path_uri(&self) -> Option<PathUri> {
PathUri::try_from(self.clone()).ok() PathUri::try_from(self.clone()).ok()
@@ -364,6 +413,21 @@ pub enum LegacyAppPathStringError {
path: String, path: String,
convention: Option<PathConvention>, convention: Option<PathConvention>,
}, },
#[error("path URI `{cwd}` has no path convention")]
MissingBaseConvention { cwd: String },
#[error("cannot resolve home-relative path `{path}` without an executor home")]
MissingHomeDirectory { path: String },
#[error(
"path {path} uses {path_convention} paths, but executor cwd {cwd} uses {convention} paths"
)]
MismatchedConvention {
path: String,
path_convention: PathConvention,
cwd: String,
convention: PathConvention,
},
#[error(transparent)]
PathUri(#[from] PathUriParseError),
} }
#[cfg(test)] #[cfg(test)]
@@ -466,6 +466,74 @@ fn converts_absolute_api_paths_using_the_inferred_convention() {
} }
} }
#[test]
fn resolves_legacy_paths_against_executor_context() {
for (cwd, path, user_home_dir, expected) in [
(
"file:///workspace",
"relative.txt",
None,
"file:///workspace/relative.txt",
),
(
"file:///C:/workspace",
"/Windows",
None,
"file:///C:/Windows",
),
(
"file:///C:/workspace",
"//server/share/file.txt",
None,
"file://server/share/file.txt",
),
(
"file:///workspace",
"~//notes",
Some("file:///home/executor"),
"file:///home/executor/notes",
),
(
"file:///C:/workspace",
r"~\\notes",
Some("file:///C:/Users/executor"),
"file:///C:/Users/executor/notes",
),
] {
let cwd = PathUri::parse(cwd).expect("valid cwd");
let user_home_dir = user_home_dir.map(|home| PathUri::parse(home).expect("valid home"));
assert_eq!(
LegacyAppPathString::from_string(path).resolve_against(&cwd, user_home_dir.as_ref()),
Ok(PathUri::parse(expected).expect("valid expected path")),
"resolving {path:?} against {cwd}"
);
}
}
#[test]
fn rejects_legacy_paths_without_executor_context() {
let cwd = PathUri::parse("file:///workspace").expect("valid cwd");
assert_eq!(
LegacyAppPathString::from_string(r"C:\\tmp")
.resolve_against(&cwd, /*user_home_dir*/ None),
Err(LegacyAppPathStringError::MismatchedConvention {
path: r"C:\\tmp".to_string(),
path_convention: PathConvention::Windows,
cwd: cwd.to_string(),
convention: PathConvention::Posix,
})
);
assert_eq!(
LegacyAppPathString::from_string("~/secret")
.resolve_against(&cwd, /*user_home_dir*/ None),
Err(LegacyAppPathStringError::MissingHomeDirectory {
path: "~/secret".to_string(),
})
);
}
#[test] #[test]
fn ambiguous_absolute_api_paths_preserve_their_inferred_convention() { fn ambiguous_absolute_api_paths_preserve_their_inferred_convention() {
for (raw_path, convention) in [ for (raw_path, convention) in [