mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Sandbox executor skill resource reads (#36121)
## Why Executor-backed skill resources must honor the active filesystem permission profile instead of reading outside its allowed roots. ## What changed - Propagate each environment's filesystem sandbox context into `skills.read` calls. - Apply turn-scoped permission grants when reading executor skill resources. - Fail closed when no matching sandbox context exists or a restricted Windows read cannot be sandboxed. - Preserve the existing resource size limit for both sandboxed and streamed reads. ## Testing - Cover reads within permitted roots and reads that succeed after a permission grant. - Verify denied references do not expose their contents and disabled Windows sandboxing is rejected. GitOrigin-RevId: 5cc679b9385b0621665ff20cf5848863cc6396a4
This commit is contained in:
@@ -4,9 +4,13 @@ use anyhow::Result;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::CapabilityRootLocation;
|
||||
use codex_app_server_protocol::GrantedPermissionProfile;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::PermissionGrantScope;
|
||||
use codex_app_server_protocol::PermissionsRequestApprovalResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::SelectedCapabilityRoot;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
@@ -15,6 +19,8 @@ use codex_app_server_protocol::WarningNotification;
|
||||
use codex_exec_server::CreateDirectoryOptions;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::skip_if_remote;
|
||||
use core_test_support::skip_if_target_windows;
|
||||
use futures::StreamExt;
|
||||
use futures::TryStreamExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -22,6 +28,9 @@ use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const SKILL_NAME: &str = "demo-plugin:deploy";
|
||||
const SKILL_MARKER: &str = "EXECUTOR_SKILL_BODY_MARKER";
|
||||
@@ -32,6 +41,8 @@ const REFERENCE_MARKER: &str = "EXECUTOR_SKILL_REFERENCE_MARKER";
|
||||
enum ExecutorSkillScenario {
|
||||
VisibleWithBudgetWarning,
|
||||
ExplicitOnly,
|
||||
RestrictedPermittedReference,
|
||||
RestrictedDeniedReference,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -44,16 +55,59 @@ async fn explicit_executor_skill_can_read_referenced_file() -> Result<()> {
|
||||
exercise_executor_skill(ExecutorSkillScenario::ExplicitOnly).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restricted_executor_skill_can_read_permitted_reference() -> Result<()> {
|
||||
exercise_executor_skill(ExecutorSkillScenario::RestrictedPermittedReference).await
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn restricted_executor_skill_rejects_reference_until_permission_approved() -> Result<()> {
|
||||
exercise_executor_skill(ExecutorSkillScenario::RestrictedDeniedReference).await
|
||||
}
|
||||
|
||||
async fn exercise_executor_skill(scenario: ExecutorSkillScenario) -> Result<()> {
|
||||
let restricted = matches!(
|
||||
scenario,
|
||||
ExecutorSkillScenario::RestrictedPermittedReference
|
||||
| ExecutorSkillScenario::RestrictedDeniedReference
|
||||
);
|
||||
if restricted {
|
||||
skip_if_target_windows!(
|
||||
Ok(()),
|
||||
"the unelevated Windows sandbox cannot enforce restricted filesystem reads"
|
||||
);
|
||||
}
|
||||
if scenario == ExecutorSkillScenario::RestrictedDeniedReference {
|
||||
skip_if_remote!(Ok(()), "the external symlink fixture is host-local");
|
||||
}
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let codex_home = TempDir::new()?;
|
||||
let (sandbox_config, permission_profile) = if restricted {
|
||||
(
|
||||
"default_permissions = \"workspace\"",
|
||||
"\n[permissions.workspace.filesystem.\":workspace_roots\"]\n\".\" = \"write\"\n\n[windows]\nsandbox = \"unelevated\"\n",
|
||||
)
|
||||
} else {
|
||||
("sandbox_mode = \"read-only\"", "")
|
||||
};
|
||||
let (approval_policy, requested_permission_feature) =
|
||||
if scenario == ExecutorSkillScenario::RestrictedDeniedReference {
|
||||
(
|
||||
"on-request",
|
||||
"\n[features]\nrequest_permissions_tool = true\n",
|
||||
)
|
||||
} else {
|
||||
("never", "")
|
||||
};
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
format!(
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "read-only"
|
||||
approval_policy = "{approval_policy}"
|
||||
{sandbox_config}
|
||||
model_provider = "mock_provider"
|
||||
|
||||
[skills]
|
||||
@@ -65,6 +119,8 @@ base_url = "{}/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
{permission_profile}
|
||||
{requested_permission_feature}
|
||||
"#,
|
||||
server.uri()
|
||||
),
|
||||
@@ -104,7 +160,9 @@ stream_max_retries = 0
|
||||
let reference_path = reference_dir.join("details.md")?;
|
||||
let reference_size = match scenario {
|
||||
ExecutorSkillScenario::VisibleWithBudgetWarning => 600 * 1024,
|
||||
ExecutorSkillScenario::ExplicitOnly => 40 * 1024,
|
||||
ExecutorSkillScenario::ExplicitOnly
|
||||
| ExecutorSkillScenario::RestrictedPermittedReference
|
||||
| ExecutorSkillScenario::RestrictedDeniedReference => 40 * 1024,
|
||||
};
|
||||
let allow_implicit_invocation = scenario == ExecutorSkillScenario::VisibleWithBudgetWarning;
|
||||
let reference_contents = format!("{REFERENCE_MARKER}\n{}", "x".repeat(reference_size));
|
||||
@@ -136,6 +194,19 @@ stream_max_retries = 0
|
||||
/*sandbox*/ None,
|
||||
),
|
||||
)?;
|
||||
#[cfg(unix)]
|
||||
if scenario == ExecutorSkillScenario::RestrictedDeniedReference {
|
||||
let external_reference_dir = codex_home.path().join("external-reference");
|
||||
std::fs::create_dir_all(&external_reference_dir)?;
|
||||
let external_reference = external_reference_dir.join("details.md");
|
||||
std::fs::write(
|
||||
&external_reference,
|
||||
format!("DENIED_REFERENCE_MARKER\n{REFERENCE_MARKER}"),
|
||||
)?;
|
||||
let reference_native_path = reference_path.to_abs_path()?;
|
||||
std::fs::remove_file(reference_native_path.as_path())?;
|
||||
std::os::unix::fs::symlink(external_reference, reference_native_path.as_path())?;
|
||||
}
|
||||
if scenario == ExecutorSkillScenario::VisibleWithBudgetWarning {
|
||||
futures::stream::iter(0..200)
|
||||
.map(|index| {
|
||||
@@ -194,21 +265,56 @@ stream_max_retries = 0
|
||||
responses::ev_completed(&format!("resp-{call_id}")),
|
||||
])
|
||||
};
|
||||
let response_mock = responses::mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
tool_response("list", "list", json!({"authority": {"kind": "executor"}})),
|
||||
let mut model_responses = vec![
|
||||
tool_response("list", "list", json!({"authority": {"kind": "executor"}})),
|
||||
tool_response(
|
||||
"main",
|
||||
"read",
|
||||
json!({
|
||||
"authority": {"kind": "executor", "id": authority_id},
|
||||
"package": package.clone(),
|
||||
"resource": main_resource.clone(),
|
||||
}),
|
||||
),
|
||||
tool_response(
|
||||
"reference",
|
||||
"read",
|
||||
json!({
|
||||
"authority": {"kind": "executor", "id": authority_id},
|
||||
"package": package.clone(),
|
||||
"resource": reference_resource.clone(),
|
||||
}),
|
||||
),
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-done"),
|
||||
responses::ev_assistant_message("msg-done", "Done"),
|
||||
responses::ev_completed("resp-done"),
|
||||
]),
|
||||
];
|
||||
if scenario == ExecutorSkillScenario::RestrictedDeniedReference {
|
||||
let external_reference_dir = codex_home.path().join("external-reference");
|
||||
model_responses.insert(
|
||||
3,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-permissions"),
|
||||
responses::ev_function_call(
|
||||
"permissions",
|
||||
"request_permissions",
|
||||
&json!({
|
||||
"reason": "Read the approved skill reference",
|
||||
"permissions": {
|
||||
"file_system": {"read": [external_reference_dir]}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
responses::ev_completed("resp-permissions"),
|
||||
]),
|
||||
);
|
||||
model_responses.insert(
|
||||
4,
|
||||
tool_response(
|
||||
"main",
|
||||
"read",
|
||||
json!({
|
||||
"authority": {"kind": "executor", "id": authority_id},
|
||||
"package": package.clone(),
|
||||
"resource": main_resource.clone(),
|
||||
}),
|
||||
),
|
||||
tool_response(
|
||||
"reference",
|
||||
"approved-reference",
|
||||
"read",
|
||||
json!({
|
||||
"authority": {"kind": "executor", "id": authority_id},
|
||||
@@ -216,14 +322,9 @@ stream_max_retries = 0
|
||||
"resource": reference_resource.clone(),
|
||||
}),
|
||||
),
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-done"),
|
||||
responses::ev_assistant_message("msg-done", "Done"),
|
||||
responses::ev_completed("resp-done"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
);
|
||||
}
|
||||
let response_mock = responses::mount_sse_sequence(&server, model_responses).await;
|
||||
|
||||
timeout(READ_TIMEOUT, app_server.initialize()).await??;
|
||||
|
||||
@@ -263,6 +364,26 @@ stream_max_retries = 0
|
||||
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
if scenario == ExecutorSkillScenario::RestrictedDeniedReference {
|
||||
let request =
|
||||
timeout(READ_TIMEOUT, app_server.read_stream_until_request_message()).await??;
|
||||
let ServerRequest::PermissionsRequestApproval { request_id, params } = request else {
|
||||
panic!("expected a skill reference permissions request, got {request:?}");
|
||||
};
|
||||
app_server
|
||||
.send_response(
|
||||
request_id,
|
||||
serde_json::to_value(PermissionsRequestApprovalResponse {
|
||||
permissions: GrantedPermissionProfile {
|
||||
network: None,
|
||||
file_system: params.permissions.file_system,
|
||||
},
|
||||
scope: PermissionGrantScope::Turn,
|
||||
strict_auto_review: None,
|
||||
})?,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
if scenario == ExecutorSkillScenario::VisibleWithBudgetWarning {
|
||||
let warning = timeout(READ_TIMEOUT, async {
|
||||
loop {
|
||||
@@ -313,7 +434,9 @@ stream_max_retries = 0
|
||||
ExecutorSkillScenario::VisibleWithBudgetWarning => {
|
||||
assert!(!skill_fragment.contains("<resource_access>"));
|
||||
}
|
||||
ExecutorSkillScenario::ExplicitOnly => {
|
||||
ExecutorSkillScenario::ExplicitOnly
|
||||
| ExecutorSkillScenario::RestrictedPermittedReference
|
||||
| ExecutorSkillScenario::RestrictedDeniedReference => {
|
||||
let resource_access = skill_fragment
|
||||
.split_once("<resource_access>")
|
||||
.and_then(|(_, rest)| rest.split_once("</resource_access>"))
|
||||
@@ -353,7 +476,9 @@ stream_max_retries = 0
|
||||
);
|
||||
assert!(list_output["next_cursor"].is_string());
|
||||
}
|
||||
ExecutorSkillScenario::ExplicitOnly => {
|
||||
ExecutorSkillScenario::ExplicitOnly
|
||||
| ExecutorSkillScenario::RestrictedPermittedReference
|
||||
| ExecutorSkillScenario::RestrictedDeniedReference => {
|
||||
assert_eq!(list_output["skills"], json!([]));
|
||||
}
|
||||
}
|
||||
@@ -363,11 +488,19 @@ stream_max_retries = 0
|
||||
.expect("main skill output")
|
||||
.contains(SKILL_MARKER)
|
||||
);
|
||||
let reference_output = serde_json::from_str::<serde_json::Value>(
|
||||
&requests[3]
|
||||
.function_call_output_text("reference")
|
||||
.expect("referenced skill file output"),
|
||||
)?;
|
||||
let reference_output_text = requests[3]
|
||||
.function_call_output_text("reference")
|
||||
.expect("referenced skill file output");
|
||||
if scenario == ExecutorSkillScenario::RestrictedDeniedReference {
|
||||
assert!(reference_output_text.contains("failed to read skill resource"));
|
||||
assert!(!reference_output_text.contains("DENIED_REFERENCE_MARKER"));
|
||||
let approved_reference_output = requests[5]
|
||||
.function_call_output_text("approved-reference")
|
||||
.expect("approved skill reference output");
|
||||
assert!(approved_reference_output.contains(REFERENCE_MARKER));
|
||||
return Ok(());
|
||||
}
|
||||
let reference_output = serde_json::from_str::<serde_json::Value>(&reference_output_text)?;
|
||||
assert!(
|
||||
reference_output["contents"]
|
||||
.as_str()
|
||||
@@ -377,7 +510,9 @@ stream_max_retries = 0
|
||||
ExecutorSkillScenario::VisibleWithBudgetWarning => {
|
||||
assert!(reference_output["next_cursor"].is_string());
|
||||
}
|
||||
ExecutorSkillScenario::ExplicitOnly => {
|
||||
ExecutorSkillScenario::ExplicitOnly
|
||||
| ExecutorSkillScenario::RestrictedPermittedReference
|
||||
| ExecutorSkillScenario::RestrictedDeniedReference => {
|
||||
assert!(reference_output["next_cursor"].is_null());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3068,6 +3068,26 @@ impl Session {
|
||||
.await;
|
||||
let extension_data = codex_extension_api::ExtensionData::new(turn_context.sub_id.clone());
|
||||
extension_data.insert(selected_capability_roots.clone());
|
||||
if !turn_context
|
||||
.config
|
||||
.permissions
|
||||
.file_system_sandbox_policy()
|
||||
.has_full_disk_read_access()
|
||||
{
|
||||
let sandbox_contexts = environments
|
||||
.turn_environments()
|
||||
.map(|environment| {
|
||||
(
|
||||
environment.environment_id.clone(),
|
||||
turn_context.file_system_sandbox_context(
|
||||
/*additional_permissions*/ None,
|
||||
environment,
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
extension_data.insert(sandbox_contexts);
|
||||
}
|
||||
let (mcp, prepared_recommendations) = async {
|
||||
tokio::join!(
|
||||
self.mcp_runtime_for_step(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_core_skills::HostSkillsSnapshot;
|
||||
use codex_core_skills::injection::HostSkillsCatalogInWorldState;
|
||||
use codex_core_skills::injection::InjectedHostSkillPrompts;
|
||||
use codex_exec_server::FileSystemSandboxContext;
|
||||
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
|
||||
use codex_exec_server::ResolvedSelectedCapabilityRoot;
|
||||
use codex_extension_api::ConfigContributor;
|
||||
@@ -425,7 +427,12 @@ where
|
||||
session_store: &ExtensionData,
|
||||
thread_store: &ExtensionData,
|
||||
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
|
||||
self.build_skill_tools(session_store, thread_store, /*executor_query*/ None)
|
||||
self.build_skill_tools(
|
||||
session_store,
|
||||
thread_store,
|
||||
/*executor_query*/ None,
|
||||
/*sandbox_contexts*/ None,
|
||||
)
|
||||
}
|
||||
|
||||
fn tools_for_step(
|
||||
@@ -452,7 +459,12 @@ where
|
||||
mcp_resources: None,
|
||||
executor_capability_discovery: None,
|
||||
});
|
||||
self.build_skill_tools(session_store, thread_store, executor_query)
|
||||
self.build_skill_tools(
|
||||
session_store,
|
||||
thread_store,
|
||||
executor_query,
|
||||
step_store.get::<HashMap<String, FileSystemSandboxContext>>(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -679,6 +691,7 @@ impl<C> SkillsExtension<C> {
|
||||
session_store: &ExtensionData,
|
||||
thread_store: &ExtensionData,
|
||||
executor_query: Option<SkillListQuery>,
|
||||
sandbox_contexts: Option<Arc<HashMap<String, FileSystemSandboxContext>>>,
|
||||
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
|
||||
let Some(thread_state) = thread_store.get::<SkillsThreadState>() else {
|
||||
return Vec::new();
|
||||
@@ -697,6 +710,7 @@ impl<C> SkillsExtension<C> {
|
||||
thread_state,
|
||||
orchestrator_available,
|
||||
executor_query,
|
||||
sandbox_contexts,
|
||||
Arc::clone(&self.shadow_selection),
|
||||
)
|
||||
}
|
||||
@@ -742,6 +756,7 @@ impl<C> SkillsExtension<C> {
|
||||
package: entry.id.clone(),
|
||||
resource: entry.main_prompt.clone(),
|
||||
resolved_executor_roots: Vec::new(),
|
||||
sandbox: None,
|
||||
host_snapshot,
|
||||
mcp_resources,
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ mod orchestrator;
|
||||
|
||||
use codex_core_skills::HostSkillsSnapshot;
|
||||
use codex_exec_server::ExecutorCapabilityDiscoverySnapshot;
|
||||
use codex_exec_server::FileSystemSandboxContext;
|
||||
use codex_exec_server::ResolvedSelectedCapabilityRoot;
|
||||
use codex_mcp::McpResourceClient;
|
||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
@@ -46,6 +47,7 @@ pub struct SkillReadRequest {
|
||||
pub package: SkillPackageId,
|
||||
pub resource: SkillResourceId,
|
||||
pub resolved_executor_roots: Vec<ResolvedSelectedCapabilityRoot>,
|
||||
pub sandbox: Option<FileSystemSandboxContext>,
|
||||
pub host_snapshot: Option<Arc<HostSkillsSnapshot>>,
|
||||
pub mcp_resources: Option<Arc<McpResourceClient>>,
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::sync::Arc;
|
||||
use codex_core_skills::loader::load_environment_skills_from_discovery;
|
||||
use codex_core_skills::loader::load_environment_skills_from_root;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_exec_server::FileSystemSandboxContext;
|
||||
use codex_protocol::capabilities::CapabilityRootLocation;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_skills::EnvironmentSkillMetadata;
|
||||
@@ -147,6 +148,7 @@ impl SkillProvider for ExecutorSkillProvider {
|
||||
file_system.as_ref(),
|
||||
resource_path,
|
||||
request.resource.as_str(),
|
||||
request.sandbox.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -265,25 +267,58 @@ async fn read_bounded_text(
|
||||
file_system: &dyn codex_exec_server::ExecutorFileSystem,
|
||||
path: &PathUri,
|
||||
resource: &str,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> Result<String, SkillProviderError> {
|
||||
let read_error = |err| {
|
||||
SkillProviderError::new(format!(
|
||||
"failed to read executor skill resource {resource}: {err}"
|
||||
))
|
||||
};
|
||||
let mut stream = file_system
|
||||
.read_file_stream(path, /*sandbox*/ None)
|
||||
.await
|
||||
.map_err(&read_error)?;
|
||||
let mut contents = Vec::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(&read_error)?;
|
||||
if contents.len().saturating_add(chunk.len()) > MAX_SKILL_RESOURCE_CONTENT_BYTES {
|
||||
let contents = if sandbox.is_some_and(FileSystemSandboxContext::should_run_in_sandbox) {
|
||||
if path.infer_path_convention() == Some(PathConvention::Windows)
|
||||
&& sandbox.is_some_and(|context| {
|
||||
context.windows_sandbox_level
|
||||
== codex_protocol::config_types::WindowsSandboxLevel::Disabled
|
||||
})
|
||||
{
|
||||
return Err(SkillProviderError::new(
|
||||
"executor skill resource requires an unavailable filesystem sandbox",
|
||||
));
|
||||
}
|
||||
let metadata = file_system
|
||||
.get_metadata(path, sandbox)
|
||||
.await
|
||||
.map_err(&read_error)?;
|
||||
if metadata.size > MAX_SKILL_RESOURCE_CONTENT_BYTES as u64 {
|
||||
return Err(SkillProviderError::new(format!(
|
||||
"executor skill resource {resource} exceeds {MAX_SKILL_RESOURCE_CONTENT_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
contents.extend_from_slice(&chunk);
|
||||
file_system
|
||||
.read_file(path, sandbox)
|
||||
.await
|
||||
.map_err(&read_error)?
|
||||
} else {
|
||||
let mut stream = file_system
|
||||
.read_file_stream(path, sandbox)
|
||||
.await
|
||||
.map_err(&read_error)?;
|
||||
let mut contents = Vec::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(&read_error)?;
|
||||
if contents.len().saturating_add(chunk.len()) > MAX_SKILL_RESOURCE_CONTENT_BYTES {
|
||||
return Err(SkillProviderError::new(format!(
|
||||
"executor skill resource {resource} exceeds {MAX_SKILL_RESOURCE_CONTENT_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
contents.extend_from_slice(&chunk);
|
||||
}
|
||||
contents
|
||||
};
|
||||
if contents.len() > MAX_SKILL_RESOURCE_CONTENT_BYTES {
|
||||
return Err(SkillProviderError::new(format!(
|
||||
"executor skill resource {resource} exceeds {MAX_SKILL_RESOURCE_CONTENT_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
String::from_utf8(contents).map_err(|_| {
|
||||
SkillProviderError::new(format!(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::Hash;
|
||||
use std::hash::Hasher;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_exec_server::FileSystemSandboxContext;
|
||||
use codex_extension_api::FunctionCallError;
|
||||
use codex_extension_api::JsonToolOutput;
|
||||
use codex_extension_api::ResponsesApiTool;
|
||||
@@ -44,6 +46,7 @@ pub(crate) fn skill_tools(
|
||||
thread_state: Arc<SkillsThreadState>,
|
||||
orchestrator_available: bool,
|
||||
executor_query: Option<SkillListQuery>,
|
||||
sandbox_contexts: Option<Arc<HashMap<String, FileSystemSandboxContext>>>,
|
||||
shadow_selection: Arc<ShadowSelectionExperiment>,
|
||||
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
|
||||
let context = SkillToolContext {
|
||||
@@ -52,6 +55,7 @@ pub(crate) fn skill_tools(
|
||||
thread_state,
|
||||
orchestrator_available,
|
||||
executor_query,
|
||||
sandbox_contexts,
|
||||
executor_catalog: Arc::new(OnceCell::new()),
|
||||
shadow_selection,
|
||||
};
|
||||
@@ -70,6 +74,7 @@ struct SkillToolContext {
|
||||
thread_state: Arc<SkillsThreadState>,
|
||||
orchestrator_available: bool,
|
||||
executor_query: Option<SkillListQuery>,
|
||||
sandbox_contexts: Option<Arc<HashMap<String, FileSystemSandboxContext>>>,
|
||||
executor_catalog: Arc<OnceCell<SkillCatalog>>,
|
||||
shadow_selection: Arc<ShadowSelectionExperiment>,
|
||||
}
|
||||
|
||||
@@ -96,6 +96,27 @@ impl ToolExecutor<ToolCall> for ReadTool {
|
||||
.as_ref()
|
||||
.map(|query| query.resolved_executor_roots.clone())
|
||||
.unwrap_or_default();
|
||||
let sandbox = requested_resource
|
||||
.environment_path()
|
||||
.and_then(|(environment_id, _)| {
|
||||
self.context.sandbox_contexts.as_ref().and_then(|contexts| {
|
||||
contexts.get(environment_id).map(|captured| {
|
||||
call.environments
|
||||
.iter()
|
||||
.find(|environment| environment.environment_id == environment_id)
|
||||
.map(|environment| environment.file_system_sandbox_context.clone())
|
||||
.unwrap_or_else(|| captured.clone())
|
||||
})
|
||||
})
|
||||
});
|
||||
if self.context.sandbox_contexts.is_some()
|
||||
&& requested_resource.environment_path().is_some()
|
||||
&& sandbox.is_none()
|
||||
{
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"failed to read skill resource".to_string(),
|
||||
));
|
||||
}
|
||||
let result = self
|
||||
.context
|
||||
.thread_state
|
||||
@@ -106,6 +127,7 @@ impl ToolExecutor<ToolCall> for ReadTool {
|
||||
package,
|
||||
resource: requested_resource.clone(),
|
||||
resolved_executor_roots,
|
||||
sandbox,
|
||||
host_snapshot: None,
|
||||
mcp_resources: self.context.mcp_resources.clone(),
|
||||
},
|
||||
|
||||
@@ -20,8 +20,15 @@ use codex_exec_server::ReadDirectoryEntry;
|
||||
use codex_exec_server::RemoveOptions;
|
||||
use codex_protocol::capabilities::CapabilityRootLocation;
|
||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_skills_extension::ExecutorSkillProvider;
|
||||
use codex_skills_extension::catalog::SkillAuthority;
|
||||
use codex_skills_extension::catalog::SkillPackageId;
|
||||
use codex_skills_extension::catalog::SkillResourceId;
|
||||
use codex_skills_extension::catalog::SkillSourceKind;
|
||||
use codex_skills_extension::provider::SkillListQuery;
|
||||
use codex_skills_extension::provider::SkillProvider;
|
||||
use codex_skills_extension::provider::SkillReadRequest;
|
||||
@@ -234,6 +241,42 @@ async fn skill_loading_and_reads_use_the_supplied_executor_file_system() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn windows_executor_skill_read_rejects_disabled_sandbox_on_any_orchestrator() {
|
||||
let provider = ExecutorSkillProvider::new_with_restriction_product(
|
||||
Arc::new(EnvironmentManager::default_for_tests()),
|
||||
/*restriction_product*/ None,
|
||||
);
|
||||
let sandbox = FileSystemSandboxContext::from_permission_profile(
|
||||
PermissionProfile::from_runtime_permissions(
|
||||
&FileSystemSandboxPolicy::restricted(Vec::new()),
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
),
|
||||
);
|
||||
let resource = SkillResourceId::environment(
|
||||
"skill://windows-root/C:/skill/SKILL.md",
|
||||
"local",
|
||||
PathUri::parse("file:///C:/skill/SKILL.md").expect("Windows resource URI"),
|
||||
);
|
||||
let error = provider
|
||||
.read(SkillReadRequest {
|
||||
authority: SkillAuthority::new(SkillSourceKind::Executor, "windows-root"),
|
||||
package: SkillPackageId("skill://windows-root/C:/skill".into()),
|
||||
resource,
|
||||
resolved_executor_roots: Vec::new(),
|
||||
sandbox: Some(sandbox),
|
||||
host_snapshot: None,
|
||||
mcp_resources: None,
|
||||
})
|
||||
.await
|
||||
.expect_err("disabled Windows sandbox must fail closed");
|
||||
|
||||
assert_eq!(
|
||||
error.message,
|
||||
"executor skill resource requires an unavailable filesystem sandbox"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn selected_root_id_distinguishes_identical_executor_paths() {
|
||||
let root_label = if cfg!(unix) {
|
||||
@@ -346,6 +389,7 @@ async fn high_level_discovery_reuses_materialized_skill_contents_for_reads() {
|
||||
package: entry.id.clone(),
|
||||
resource: entry.main_prompt.clone(),
|
||||
resolved_executor_roots: Vec::new(),
|
||||
sandbox: None,
|
||||
host_snapshot: None,
|
||||
mcp_resources: None,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user