Compare commits

..

2 Commits

Author SHA1 Message Date
alexsong-oai
ec32866c37 Pass platform param to featured plugins (#15348) 2026-03-21 01:42:40 +00:00
Dylan Hurd
60c59a7799 fix(core) disable command_might_be_dangerous when unsandboxed (#15036)
## Summary
If we are in a mode that is already explicitly un-sandboxed, then
`ApprovalPolicy::Never` should not block dangerous commands.

## Testing
- [x] Existing unit test covers old behavior
- [x] Added a unit test for this new case
2026-03-21 01:28:25 +00:00
8 changed files with 212 additions and 13 deletions

View File

@@ -25,6 +25,7 @@ use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
@@ -678,6 +679,7 @@ async fn plugin_list_force_remote_sync_reconciles_curated_plugin_state() -> Resu
.await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/featured"))
.and(query_param("platform", "codex"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(
@@ -784,6 +786,7 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> {
.await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/featured"))
.and(query_param("platform", "codex"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#))
@@ -850,6 +853,7 @@ async fn plugin_list_fetches_featured_plugin_ids_without_chatgpt_auth() -> Resul
Mock::given(method("GET"))
.and(path("/backend-api/plugins/featured"))
.and(query_param("platform", "codex"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#))
.mount(&server)
.await;
@@ -888,6 +892,7 @@ async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() ->
Mock::given(method("GET"))
.and(path("/backend-api/plugins/featured"))
.and(query_param("platform", "codex"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#))
.expect(1)
.mount(&server)

View File

@@ -309,16 +309,14 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result<Arg0PathEntryGu
#[cfg(windows)]
const PATH_SEPARATOR: &str = ";";
let updated_path_env_var = match std::env::var_os("PATH") {
Some(existing_path) => {
let mut path_env_var =
std::ffi::OsString::with_capacity(path.as_os_str().len() + 1 + existing_path.len());
path_env_var.push(path);
path_env_var.push(PATH_SEPARATOR);
path_env_var.push(existing_path);
path_env_var
let path_element = path.display();
let updated_path_env_var = match std::env::var("PATH") {
Ok(existing_path) => {
format!("{path_element}{PATH_SEPARATOR}{existing_path}")
}
Err(_) => {
format!("{path_element}")
}
None => path.as_os_str().to_owned(),
};
unsafe {

View File

@@ -549,7 +549,7 @@ pub fn render_decision_for_unmatched_command(
// On Windows, ReadOnly sandbox is not a real sandbox, so special-case it
// here.
let runtime_sandbox_provides_safety =
let environment_lacks_sandbox_protections =
cfg!(windows) && matches!(sandbox_policy, SandboxPolicy::ReadOnly { .. });
// If the command is flagged as dangerous or we have no sandbox protection,
@@ -558,9 +558,20 @@ pub fn render_decision_for_unmatched_command(
// We prefer to prompt the user rather than outright forbid the command,
// but if the user has explicitly disabled prompts, we must
// forbid the command.
if command_might_be_dangerous(command) || runtime_sandbox_provides_safety {
if command_might_be_dangerous(command) || environment_lacks_sandbox_protections {
return match approval_policy {
AskForApproval::Never => Decision::Forbidden,
AskForApproval::Never => {
let sandbox_is_explicitly_disabled = matches!(
sandbox_policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
);
if sandbox_is_explicitly_disabled {
// If the sandbox is explicitly disabled, we should allow the command to run
Decision::Allow
} else {
Decision::Forbidden
}
}
AskForApproval::OnFailure
| AskForApproval::OnRequest
| AskForApproval::UnlessTrusted

View File

@@ -81,6 +81,10 @@ fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::unrestricted()
}
fn external_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::external_sandbox()
}
async fn test_config() -> (TempDir, Config) {
let home = TempDir::new().expect("create temp dir");
let config = ConfigBuilder::default()
@@ -1686,3 +1690,100 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
(unless AskForApproval::Never is specified)."#
);
}
#[tokio::test]
async fn dangerous_command_allowed_when_sandbox_is_explicitly_disabled() {
let command = vec_str(&["rm", "-rf", "/tmp/nonexistent"]);
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: None,
command,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ExternalSandbox {
network_access: Default::default(),
},
file_system_sandbox_policy: external_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment {
command: vec_str(&["rm", "-rf", "/tmp/nonexistent"]),
}),
},
)
.await;
}
#[tokio::test]
async fn dangerous_command_forbidden_in_external_sandbox_when_policy_matches() {
let command = vec_str(&["rm", "-rf", "/tmp/nonexistent"]);
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some("prefix_rule(pattern=['rm'], decision='prompt')".to_string()),
command,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ExternalSandbox {
network_access: Default::default(),
},
file_system_sandbox_policy: external_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
ExecApprovalRequirement::Forbidden {
reason: "approval required by policy, but AskForApproval is set to Never".to_string(),
},
)
.await;
}
struct ExecApprovalRequirementScenario {
/// Source for the Starlark `.rules` file.
policy_src: Option<String>,
command: Vec<String>,
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
file_system_sandbox_policy: FileSystemSandboxPolicy,
sandbox_permissions: SandboxPermissions,
prefix_rule: Option<Vec<String>>,
}
async fn assert_exec_approval_requirement_for_command(
test: ExecApprovalRequirementScenario,
expected_requirement: ExecApprovalRequirement,
) {
let ExecApprovalRequirementScenario {
policy_src,
command,
approval_policy,
sandbox_policy,
file_system_sandbox_policy,
sandbox_permissions,
prefix_rule,
} = test;
let policy = match policy_src {
Some(src) => {
let mut parser = PolicyParser::new();
parser
.parse("test.rules", src.as_str())
.expect("parse policy");
Arc::new(parser.build())
}
None => Arc::new(Policy::empty()),
};
let requirement = ExecPolicyManager::new(policy)
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy,
sandbox_policy: &sandbox_policy,
file_system_sandbox_policy: &file_system_sandbox_policy,
sandbox_permissions,
prefix_rule,
})
.await;
assert_eq!(requirement, expected_requirement);
}

View File

@@ -622,7 +622,8 @@ impl PluginsManager {
if let Some(featured_plugin_ids) = self.cached_featured_plugin_ids(&cache_key) {
return Ok(featured_plugin_ids);
}
let featured_plugin_ids = fetch_remote_featured_plugin_ids(config, auth).await?;
let featured_plugin_ids =
fetch_remote_featured_plugin_ids(config, auth, self.restriction_product).await?;
self.write_featured_plugin_ids_cache(cache_key, &featured_plugin_ids);
Ok(featured_plugin_ids)
}

View File

@@ -23,6 +23,7 @@ use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
let plugin_root = root.join(dir_name);
@@ -1899,6 +1900,74 @@ plugins = true
);
}
#[tokio::test]
async fn featured_plugin_ids_for_config_uses_restriction_product_query_param() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/featured"))
.and(query_param("platform", "chat"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["chat-plugin"]"#))
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = PluginsManager::new_with_restriction_product(
tmp.path().to_path_buf(),
Some(Product::Chatgpt),
);
let featured_plugin_ids = manager
.featured_plugin_ids_for_config(
&config,
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
)
.await
.unwrap();
assert_eq!(featured_plugin_ids, vec!["chat-plugin".to_string()]);
}
#[tokio::test]
async fn featured_plugin_ids_for_config_defaults_query_param_to_codex() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/featured"))
.and(query_param("platform", "codex"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["codex-plugin"]"#))
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = PluginsManager::new_with_restriction_product(tmp.path().to_path_buf(), None);
let featured_plugin_ids = manager
.featured_plugin_ids_for_config(&config, None)
.await
.unwrap();
assert_eq!(featured_plugin_ids, vec!["codex-plugin".to_string()]);
}
#[test]
fn refresh_curated_plugin_cache_replaces_existing_local_version_with_sha() {
let tmp = tempfile::tempdir().unwrap();

View File

@@ -1,6 +1,7 @@
use crate::auth::CodexAuth;
use crate::config::Config;
use crate::default_client::build_reqwest_client;
use codex_protocol::protocol::Product;
use serde::Deserialize;
use std::time::Duration;
use url::Url;
@@ -162,12 +163,17 @@ pub(crate) async fn fetch_remote_plugin_status(
pub async fn fetch_remote_featured_plugin_ids(
config: &Config,
auth: Option<&CodexAuth>,
product: Option<Product>,
) -> Result<Vec<String>, RemotePluginFetchError> {
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/plugins/featured");
let client = build_reqwest_client();
let mut request = client
.get(&url)
.query(&[(
"platform",
product.unwrap_or(Product::Codex).to_app_platform(),
)])
.timeout(REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT);
if let Some(auth) = auth.filter(|auth| auth.is_chatgpt_auth()) {

View File

@@ -2978,6 +2978,14 @@ pub enum Product {
Atlas,
}
impl Product {
pub fn to_app_platform(self) -> &'static str {
match self {
Self::Chatgpt => "chat",
Self::Codex => "codex",
Self::Atlas => "atlas",
}
}
pub fn from_session_source_name(value: &str) -> Option<Self> {
let normalized = value.trim().to_ascii_lowercase();
match normalized.as_str() {