Expand extension permission regression coverage (#41946)

## What changed

- Verify image-generation extensions rebind permissions on every turn, preserving session grants while expiring turn-only grants.
- Cover executor skill reference reads under current filesystem permissions, including paginated reads, permission changes, and edits between pages.
- Verify oversized live executor skill prompts emit a warning without replacing previously injected prompt content.

GitOrigin-RevId: 1fc8eef409fa65db4bb1ab6a00408fe72c9e938f
This commit is contained in:
Adam Perry @ OpenAI authored and copyberry committed 2026-09-01 00:42:19 +00:00
1 parent e39ab0c185
commit c2e5692e2c
2 files changed
+688

No files matched your search

@@ -22,6 +22,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::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
@@ -35,12 +36,14 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_sandbox;
use core_test_support::skip_if_target_windows;
use core_test_support::test_codex::local_selections;
use core_test_support::test_codex::test_codex;
use core_test_support::test_codex::turn_permission_fields;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde_json::json;
use test_case::test_case;
use wiremock::Mock;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
@@ -328,3 +331,253 @@ async fn extension_tool_uses_granted_turn_permissions_without_host_local_persist
Ok(())
}
/// Rebinding extension access each turn preserves session grants but expires turn-only grants.
#[test_case(PermissionGrantScope::Turn; "turn grant")]
#[test_case(PermissionGrantScope::Session; "session grant")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn extension_tool_rebinds_granted_permissions_on_each_turn(
grant_scope: PermissionGrantScope,
) -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
skip_if_target_windows!(
Ok(()),
"restricted reads require the elevated Windows sandbox backend, unavailable in this fixture"
);
let server = responses::start_mock_server().await;
Mock::given(method("POST"))
.and(path("/v1/images/edits"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"created": 1,
"data": [{"b64_json": TINY_PNG_BASE64}],
})))
.expect(match grant_scope {
PermissionGrantScope::Turn => 1,
PermissionGrantScope::Session => 2,
})
.mount(&server)
.await;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let extensions = image_generation_extensions(&auth, |_config| None);
let base_permission_profile = PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry::new(
FileSystemPath::Special {
value: FileSystemSpecialPath::Minimal,
},
FileSystemAccessMode::Read,
),
FileSystemSandboxEntry::new(
FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
FileSystemAccessMode::Write,
),
]),
NetworkSandboxPolicy::Restricted,
);
let permission_profile_for_config = base_permission_profile.clone();
let mut builder = test_codex()
.with_auth(auth)
.with_extensions(extensions)
.with_model_info_override("gpt-5.4", |model_info| {
model_info.use_responses_lite = true;
model_info.input_modalities = vec![InputModality::Text, InputModality::Image];
})
.with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
config
.permissions
.set_permission_profile(permission_profile_for_config)
.expect("set permission profile");
assert!(config.web_search_mode.set(WebSearchMode::Live).is_ok());
assert!(
config
.features
.enable(Feature::RequestPermissionsTool)
.is_ok()
);
});
let test = builder.build(&server).await?;
let image_dir = tempfile::tempdir()?;
let image_path = image_dir.path().canonicalize()?.join("granted.png");
std::fs::write(&image_path, TINY_PNG_BYTES)?;
let requested_permissions = RequestPermissionProfile {
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![AbsolutePathBuf::try_from(
image_dir.path().canonicalize()?,
)?]),
Some(Vec::new()),
)),
..RequestPermissionProfile::default()
};
let permissions_call_id = "permissions-call";
let image_call_id = "image-edit-granted";
let next_image_call_id = "image-edit-next-turn";
let response_mock = responses::mount_sse_sequence(
&server,
vec![
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call(
permissions_call_id,
"request_permissions",
&serde_json::to_string(&json!({
"reason": "Read an image outside the workspace",
"permissions": requested_permissions,
}))?,
),
responses::ev_completed("resp-1"),
]),
responses::sse(vec![
responses::ev_response_created("resp-2"),
responses::ev_function_call_with_namespace(
image_call_id,
"image_gen",
"imagegen",
&json!({
"prompt": "edit the image",
"referenced_image_paths": [image_path.display().to_string()],
})
.to_string(),
),
responses::ev_completed("resp-2"),
]),
responses::sse(vec![
responses::ev_response_created("resp-3"),
responses::ev_assistant_message("msg-1", "done"),
responses::ev_completed("resp-3"),
]),
responses::sse(vec![
responses::ev_response_created("resp-4"),
responses::ev_function_call_with_namespace(
next_image_call_id,
"image_gen",
"imagegen",
&json!({
"prompt": "edit the image again",
"referenced_image_paths": [image_path.display().to_string()],
})
.to_string(),
),
responses::ev_completed("resp-4"),
]),
responses::sse(vec![
responses::ev_response_created("resp-5"),
responses::ev_assistant_message("msg-2", "done again"),
responses::ev_completed("resp-5"),
]),
],
)
.await;
let (sandbox_policy, permission_profile) =
turn_permission_fields(base_permission_profile, test.config.cwd.as_path());
test.codex
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: "request access and edit the image".to_string(),
text_elements: Vec::new(),
}])
.with_thread_settings(ThreadSettingsOverrides {
environments: Some(local_selections(test.config.cwd.clone())),
approval_policy: Some(AskForApproval::OnRequest),
approvals_reviewer: Some(ApprovalsReviewer::User),
sandbox_policy: Some(sandbox_policy),
permission_profile,
collaboration_mode: Some(CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model: test.session_configured.model.clone(),
reasoning_effort: None,
developer_instructions: None,
},
}),
..Default::default()
}),
)
.await?;
let event = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::RequestPermissions(_) | EventMsg::TurnComplete(_)
)
})
.await;
let EventMsg::RequestPermissions(request) = event else {
panic!("expected request_permissions before turn completion");
};
assert_eq!(request.call_id, permissions_call_id);
test.codex
.submit(Op::RequestPermissionsResponse {
id: permissions_call_id.to_string(),
response: RequestPermissionsResponse {
permissions: request.permissions,
scope: grant_scope,
strict_auto_review: false,
},
})
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let request = response_mock
.last_request()
.context("missing request containing extension output")?;
let output = request.function_call_output(image_call_id);
assert_eq!(
output["output"][0],
json!({
"type": "input_image",
"image_url": TINY_PNG_DATA_URL,
})
);
test.submit_text_turn("edit the same image in a new turn")
.await?;
let next_request = response_mock
.last_request()
.context("missing request containing next-turn extension output")?;
match grant_scope {
PermissionGrantScope::Turn => {
let output = next_request
.function_call_output_content_and_success(next_image_call_id)
.and_then(|(content, _)| content)
.context("expired turn grant should produce an extension error")?;
assert!(
output.starts_with(&format!(
"unable to read referenced image at `{}`:",
image_path.display()
)),
"unexpected extension error: {output}"
);
}
PermissionGrantScope::Session => {
let output = next_request.function_call_output(next_image_call_id);
assert_eq!(
output["output"][0],
json!({
"type": "input_image",
"image_url": TINY_PNG_DATA_URL,
})
);
assert_eq!(
std::fs::read(
test.config
.cwd
.join("generated_images")
.join("image-edit-next-turn.png")
)?,
TINY_PNG_BYTES
);
}
}
Ok(())
}
@@ -26,9 +26,17 @@ use codex_login::CodexAuth;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::TruncationPolicyConfig;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::user_input::UserInput;
use codex_skills_extension::ExecutorSkillProvider;
use codex_skills_extension::HostSkillProvider;
@@ -65,14 +73,19 @@ use core_test_support::responses::sse;
use core_test_support::responses::start_websocket_server;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_remote;
use core_test_support::skip_if_target_windows;
use core_test_support::skip_if_wine_exec;
use core_test_support::test_codex::test_codex;
use core_test_support::test_codex::test_env;
use core_test_support::test_codex::turn_permission_fields;
use core_test_support::wait_for_event;
use core_test_support::wait_for_mcp_server;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use sha1::Digest;
use tempfile::TempDir;
use test_case::test_case;
use tokio::time::Duration;
use tokio::time::Instant;
use toml::toml;
@@ -1828,6 +1841,428 @@ async fn executor_only_provider_preserves_structured_repo_skill_without_discover
Ok(())
}
#[derive(Clone, Copy)]
enum ExecutorReferenceRead {
Allowed,
Denied,
Continuation,
EditedContinuation,
DeniedContinuation,
}
/// Live references and continuation pages use current permissions, including after discovery
/// materialized the main prompt. Continuations preserve the cached snapshot across file edits.
#[test_case(ExecutorReferenceRead::Allowed; "full disk read")]
#[test_case(ExecutorReferenceRead::Denied; "denied reference")]
#[test_case(ExecutorReferenceRead::Continuation; "unchanged continuation")]
#[test_case(ExecutorReferenceRead::EditedContinuation; "edited continuation")]
#[test_case(ExecutorReferenceRead::DeniedContinuation; "denied continuation")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn executor_skill_tool_reads_references_under_current_permissions(
read: ExecutorReferenceRead,
) -> Result<()> {
skip_if_no_network!(Ok(()));
if matches!(
read,
ExecutorReferenceRead::Denied | ExecutorReferenceRead::DeniedContinuation
) {
skip_if_target_windows!(
Ok(()),
"restricted reads require the elevated Windows sandbox backend, unavailable in this fixture"
);
}
const REFERENCE_CONTENTS: &str = "Live executor reference instructions.";
let contents = match read {
ExecutorReferenceRead::Allowed | ExecutorReferenceRead::Denied => {
REFERENCE_CONTENTS.to_string()
}
ExecutorReferenceRead::Continuation
| ExecutorReferenceRead::EditedContinuation
| ExecutorReferenceRead::DeniedContinuation => "reference line\n".repeat(800),
};
let reference_access = match read {
ExecutorReferenceRead::Denied => FileSystemAccessMode::Deny,
ExecutorReferenceRead::Allowed
| ExecutorReferenceRead::Continuation
| ExecutorReferenceRead::EditedContinuation
| ExecutorReferenceRead::DeniedContinuation => FileSystemAccessMode::Read,
};
let server = responses::start_mock_server().await;
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
install_with_providers(
&mut extensions,
SkillProviders::new().with_executor_provider(Arc::new(
ExecutorSkillProvider::new_with_restriction_product(
Arc::new(EnvironmentManager::default_for_tests()),
/*restriction_product*/ None,
),
)),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
orchestrator_skills_enabled: false,
shadow_selection_enabled: false,
},
);
let mut builder = test_codex()
.with_extensions(Arc::new(extensions.build()))
.with_model_info_override("gpt-5.4", |model_info| {
model_info.truncation_policy = TruncationPolicyConfig::bytes(/*limit*/ 8192);
})
.with_config(configure_catalog_test);
let test = builder.build_with_auto_env(&server).await?;
let selection = test
.codex
.environment_selections()
.await
.into_iter()
.next()
.expect("thread should select an executor environment");
let file_system = test.fs();
let skill_dir = selection.cwd.join("skill")?;
file_system
.create_directory(
&skill_dir,
CreateDirectoryOptions {
recursive: true,
follow_symlinks: true,
},
/*sandbox*/ None,
)
.await?;
// Discovery retains the selected root's spelling, including macOS /var aliases.
// Only permission entries should use the canonical path.
let policy_skill_dir = file_system
.canonicalize(&skill_dir, /*sandbox*/ None)
.await?;
for (name, contents) in [
(
"SKILL.md",
"---\nname: skill\ndescription: Read executor references.\n---\n\nRead reference.md.\n",
),
("reference.md", contents.as_str()),
] {
file_system
.write_file(
&skill_dir.join(name)?,
contents.as_bytes().to_vec(),
Default::default(),
/*sandbox*/ None,
)
.await?;
}
let package = format!(
"skill://reference-root/{}",
skill_dir
.inferred_native_path_string()
.replace('\\', "/")
.trim_start_matches('/')
);
let resource = format!("{package}/reference.md");
let mut thread_extension_init = ExtensionDataInit::new();
thread_extension_init.insert(vec![SelectedCapabilityRoot {
id: "reference-root".to_string(),
location: CapabilityRootLocation::Environment {
environment_id: selection.environment_id.clone(),
path: selection.cwd.clone(),
},
}]);
let mut config = test.config.clone();
config
.permissions
.set_permission_profile(PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry::new(
FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
FileSystemAccessMode::Read,
),
FileSystemSandboxEntry::new(
FileSystemPath::Path {
path: policy_skill_dir.join("reference.md")?,
},
reference_access,
),
]),
NetworkSandboxPolicy::Restricted,
))?;
let thread = test
.thread_manager
.start_thread(StartThreadOptions {
environments: Some(vec![selection]),
thread_extension_init,
..StartThreadOptions::new(config)
})
.await?;
let response = responses::mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(
"read-reference",
"skills",
"read",
&json!({ "package": package, "resource": resource }).to_string(),
),
ev_completed("resp-1"),
]),
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
],
)
.await;
thread
.thread
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "Read the executor skill reference.".to_string(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_event(&thread.thread, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let output = response.requests()[1]
.function_call_output_text("read-reference")
.expect("skills.read should return a tool result");
if matches!(read, ExecutorReferenceRead::Denied) {
assert_eq!(output, "failed to read skill resource");
} else if matches!(read, ExecutorReferenceRead::Allowed) {
assert_eq!(
serde_json::from_str::<Value>(&output)?,
json!({
"resource": resource,
"contents": contents,
"skill_root": skill_dir.inferred_native_path_string(),
"next_cursor": null,
})
);
} else {
let page: Value = serde_json::from_str(&output)?;
let cursor = page["next_cursor"]
.as_str()
.expect("reference needs another page");
let first_contents = page["contents"].as_str().expect("first page contents");
assert_eq!(
page,
json!({
"resource": resource,
"contents": &contents[..first_contents.len()],
"skill_root": skill_dir.inferred_native_path_string(),
"next_cursor": cursor,
})
);
if matches!(read, ExecutorReferenceRead::EditedContinuation) {
file_system
.write_file(
&skill_dir.join("reference.md")?,
b"edited reference".to_vec(),
Default::default(),
/*sandbox*/ None,
)
.await?;
}
let response = responses::mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-3"),
responses::ev_function_call_with_namespace(
"continue-reference",
"skills",
"read",
&json!({"package": package, "resource": resource, "cursor": cursor})
.to_string(),
),
ev_completed("resp-3"),
]),
sse(vec![ev_response_created("resp-4"), ev_completed("resp-4")]),
],
)
.await;
let mut input = TurnInputRequest::user_input(vec![UserInput::Text {
text: "Read the next page of the executor reference.".to_string(),
text_elements: Vec::new(),
}]);
if matches!(read, ExecutorReferenceRead::DeniedContinuation) {
let permissions = PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry::new(
FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
FileSystemAccessMode::Read,
),
FileSystemSandboxEntry::new(
FileSystemPath::Path {
path: policy_skill_dir.join("reference.md")?,
},
FileSystemAccessMode::Deny,
),
]),
NetworkSandboxPolicy::Restricted,
);
let (sandbox_policy, permission_profile) =
turn_permission_fields(permissions, test.config.cwd.as_path());
input = input.with_thread_settings(ThreadSettingsOverrides {
sandbox_policy: Some(sandbox_policy),
permission_profile,
..Default::default()
});
}
thread.thread.start_or_steer_turn(input).await?;
wait_for_event(&thread.thread, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let output = response.requests()[1]
.function_call_output_text("continue-reference")
.expect("skills.read continuation should return a tool result");
match read {
ExecutorReferenceRead::Continuation | ExecutorReferenceRead::EditedContinuation => {
assert_eq!(
serde_json::from_str::<Value>(&output)?,
json!({
"resource": resource,
"contents": &contents[first_contents.len()..],
"skill_root": skill_dir.inferred_native_path_string(),
"next_cursor": null,
})
)
}
ExecutorReferenceRead::DeniedContinuation => {
assert_eq!(output, "failed to read skill resource")
}
ExecutorReferenceRead::Allowed | ExecutorReferenceRead::Denied => {
unreachable!("single-page cases handled above")
}
}
}
Ok(())
}
/// Live executor prompts reject oversized resources without rewriting earlier injected content.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn explicit_executor_skill_prompt_rejects_oversized_resource() -> Result<()> {
skip_if_no_network!(Ok(()));
const FIRST_BODY: &str = "Instructions from the allowed live read.";
let server = responses::start_mock_server().await;
let environment = test_env().await?;
let environment_id = environment.selection().environment_id.clone();
let skill_path = environment.selection().cwd.join("SKILL.md")?;
let file_system = environment.environment().get_filesystem();
file_system
.write_file(
&skill_path,
FIRST_BODY.as_bytes().to_vec(),
Default::default(),
/*sandbox*/ None,
)
.await?;
let catalog = SkillCatalog {
entries: vec![SkillCatalogEntry::new(
SkillPackageId("skill://prompt-limit/live".to_string()),
SkillAuthority::new(SkillSourceKind::Executor, "prompt-limit"),
"live",
"Read executor instructions.",
SkillResourceId::environment(
"skill://prompt-limit/live/SKILL.md",
&environment_id,
skill_path.clone(),
),
)],
warnings: Vec::new(),
};
let environment_manager = match environment.exec_server_url() {
Some(url) => {
EnvironmentManager::create_for_tests(
Some(url.to_string()),
/*local_runtime_paths*/ None,
)
.await
}
None => EnvironmentManager::default_for_tests(),
};
let (event_tx, event_rx) = std::sync::mpsc::channel();
let mut extensions =
ExtensionRegistryBuilder::<Config>::with_event_sink(Arc::new(ChannelEventSink(event_tx)));
install_with_providers(
&mut extensions,
SkillProviders::new()
.with_executor_provider(Arc::new(CatalogSkillProvider { catalog }))
.with_executor_provider(Arc::new(
ExecutorSkillProvider::new_with_restriction_product(
Arc::new(environment_manager),
/*restriction_product*/ None,
),
)),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
orchestrator_skills_enabled: false,
shadow_selection_enabled: false,
},
);
let mut builder = test_codex()
.with_extensions(Arc::new(extensions.build()))
.with_config(configure_catalog_test);
let test = builder.build_with_environment(&server, environment).await?;
let first_response = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
test.submit_turn("$live").await?;
let expected_prompts = vec![format!(
"<skill>\n<name>live</name>\n<path>skill://prompt-limit/live/SKILL.md</path>\n{FIRST_BODY}\n</skill>"
)];
let first_prompts = first_response
.single_request()
.message_input_texts("user")
.into_iter()
.filter(|text| text.starts_with("<skill>"))
.collect::<Vec<_>>();
assert_eq!(first_prompts, expected_prompts);
file_system
.write_file(
&skill_path,
vec![b'x'; 1024 * 1024 + 1],
Default::default(),
/*sandbox*/ None,
)
.await?;
let second_response = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
)
.await;
test.submit_turn("$live").await?;
let prompts = second_response
.single_request()
.message_input_texts("user")
.into_iter()
.filter(|text| text.starts_with("<skill>"))
.collect::<Vec<_>>();
assert_eq!(prompts, expected_prompts);
let warnings = event_rx
.try_iter()
.filter_map(|event| match event {
CapturedExtensionEvent::Warning(warning) => Some(warning.message),
CapturedExtensionEvent::Event(_) => None,
})
.collect::<Vec<_>>();
assert_eq!(warnings, vec![
"Failed to load skill `live`: executor skill resource skill://prompt-limit/live/SKILL.md exceeds 1048576 bytes".to_string()
]);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn executor_skill_invocation_is_environment_scoped_and_deduplicated() -> Result<()> {
skip_if_remote!(Ok(()), "executor fixture uses a host-local skill path");