mirror of
https://github.com/openai/codex.git
synced 2026-05-22 12:04:19 +00:00
Compare commits
16 Commits
jif/code-m
...
dev/winsto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6b5f7c212 | ||
|
|
fed2ee1c25 | ||
|
|
712f956360 | ||
|
|
591b173259 | ||
|
|
8bb4b38635 | ||
|
|
1e2356c6f4 | ||
|
|
afc19347e2 | ||
|
|
74316b3b86 | ||
|
|
9fa22ca31e | ||
|
|
a4101509c5 | ||
|
|
60c59733f2 | ||
|
|
b4a1e10ce5 | ||
|
|
2c9ccbe030 | ||
|
|
af0113519a | ||
|
|
cdefa6f448 | ||
|
|
4334b19c23 |
4
codex-rs/Cargo.lock
generated
4
codex-rs/Cargo.lock
generated
@@ -2432,6 +2432,7 @@ dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
"gethostname",
|
||||
"indexmap 2.13.0",
|
||||
"libc",
|
||||
"multimap",
|
||||
"pretty_assertions",
|
||||
@@ -2492,6 +2493,7 @@ dependencies = [
|
||||
"codex-app-server-protocol",
|
||||
"codex-apply-patch",
|
||||
"codex-async-utils",
|
||||
"codex-backend-client",
|
||||
"codex-code-mode",
|
||||
"codex-config",
|
||||
"codex-connectors",
|
||||
@@ -3303,6 +3305,7 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"clap",
|
||||
"codex-utils-absolute-path",
|
||||
@@ -3318,6 +3321,7 @@ dependencies = [
|
||||
"rama-tcp",
|
||||
"rama-tls-rustls",
|
||||
"rama-unix",
|
||||
"rustls-native-certs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
|
||||
@@ -1491,10 +1491,12 @@ fn json_object_to_env_toml_table(
|
||||
object: &serde_json::Map<String, JsonValue>,
|
||||
) -> toml::map::Map<String, TomlValue> {
|
||||
let mut table = toml::map::Map::new();
|
||||
for (key, value) in object {
|
||||
if let Some(value) = json_env_value_to_string(value) {
|
||||
table.insert(key.clone(), TomlValue::String(value));
|
||||
}
|
||||
for (key, value) in object
|
||||
.iter()
|
||||
.filter_map(|(key, value)| json_env_value_to_string(value).map(|value| (key, value)))
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
{
|
||||
table.insert(key.clone(), TomlValue::String(value));
|
||||
}
|
||||
table
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::types::CodeTaskDetailsResponse;
|
||||
use crate::types::ConfigFileResponse;
|
||||
use crate::types::ListCredentialRoutesResponse;
|
||||
use crate::types::PaginatedListTaskListItem;
|
||||
use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind;
|
||||
use crate::types::RateLimitStatusPayload;
|
||||
@@ -203,12 +204,17 @@ impl Client {
|
||||
}
|
||||
|
||||
fn headers(&self) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
let mut h = self.auth_headers();
|
||||
if let Some(ua) = &self.user_agent {
|
||||
h.insert(USER_AGENT, ua.clone());
|
||||
} else {
|
||||
h.insert(USER_AGENT, HeaderValue::from_static("codex-cli"));
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn auth_headers(&self) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
self.auth_provider.add_auth_headers(&mut h);
|
||||
if let Some(acc) = &self.chatgpt_account_id
|
||||
&& let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id")
|
||||
@@ -408,6 +414,24 @@ impl Client {
|
||||
.map_err(RequestError::from)
|
||||
}
|
||||
|
||||
pub async fn list_credential_routes(&self) -> Result<ListCredentialRoutesResponse> {
|
||||
let url = self.credential_routes_url();
|
||||
let req = self.http.get(&url).headers(self.headers());
|
||||
let (body, ct) = self.exec_request(req, "GET", &url).await?;
|
||||
self.decode_json(&url, &ct, &body)
|
||||
}
|
||||
|
||||
pub fn credential_routes_proxy_url(&self) -> String {
|
||||
match self.path_style {
|
||||
PathStyle::CodexApi => format!("{}/api/codex/credential_routes/proxy", self.base_url),
|
||||
PathStyle::ChatGptApi => format!("{}/wham/credential_routes/proxy", self.base_url),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn credential_routes_proxy_auth_headers(&self) -> HeaderMap {
|
||||
self.auth_headers()
|
||||
}
|
||||
|
||||
/// Create a new task (user turn) by POSTing to the appropriate backend path
|
||||
/// based on `path_style`. Returns the created task id.
|
||||
pub async fn create_task(&self, request_body: serde_json::Value) -> Result<String> {
|
||||
@@ -539,6 +563,13 @@ impl Client {
|
||||
}
|
||||
}
|
||||
|
||||
fn credential_routes_url(&self) -> String {
|
||||
match self.path_style {
|
||||
PathStyle::CodexApi => format!("{}/api/codex/credential_routes", self.base_url),
|
||||
PathStyle::ChatGptApi => format!("{}/wham/credential_routes", self.base_url),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_rate_limit_window(
|
||||
window: Option<Option<Box<crate::types::RateLimitWindowSnapshot>>>,
|
||||
) -> Option<RateLimitWindow> {
|
||||
@@ -862,4 +893,43 @@ mod tests {
|
||||
serde_json::json!({ "credit_type": "usage_limit" })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_routes_use_expected_paths() {
|
||||
let codex_client = Client {
|
||||
base_url: "https://example.test".to_string(),
|
||||
http: reqwest::Client::new(),
|
||||
auth_provider: codex_model_provider::unauthenticated_auth_provider(),
|
||||
user_agent: None,
|
||||
chatgpt_account_id: None,
|
||||
chatgpt_account_is_fedramp: false,
|
||||
path_style: PathStyle::CodexApi,
|
||||
};
|
||||
assert_eq!(
|
||||
codex_client.credential_routes_url(),
|
||||
"https://example.test/api/codex/credential_routes"
|
||||
);
|
||||
assert_eq!(
|
||||
codex_client.credential_routes_proxy_url(),
|
||||
"https://example.test/api/codex/credential_routes/proxy"
|
||||
);
|
||||
|
||||
let chatgpt_client = Client {
|
||||
base_url: "https://chatgpt.com/backend-api".to_string(),
|
||||
http: reqwest::Client::new(),
|
||||
auth_provider: codex_model_provider::unauthenticated_auth_provider(),
|
||||
user_agent: None,
|
||||
chatgpt_account_id: None,
|
||||
chatgpt_account_is_fedramp: false,
|
||||
path_style: PathStyle::ChatGptApi,
|
||||
};
|
||||
assert_eq!(
|
||||
chatgpt_client.credential_routes_url(),
|
||||
"https://chatgpt.com/backend-api/wham/credential_routes"
|
||||
);
|
||||
assert_eq!(
|
||||
chatgpt_client.credential_routes_proxy_url(),
|
||||
"https://chatgpt.com/backend-api/wham/credential_routes/proxy"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ pub use client::RequestError;
|
||||
pub use types::CodeTaskDetailsResponse;
|
||||
pub use types::CodeTaskDetailsResponseExt;
|
||||
pub use types::ConfigFileResponse;
|
||||
pub use types::CredentialRouteAuthType;
|
||||
pub use types::ListCredentialRoutesResponse;
|
||||
pub use types::PaginatedListTaskListItem;
|
||||
pub use types::ResolvedCredentialRoute;
|
||||
pub use types::TaskListItem;
|
||||
pub use types::TurnAttemptsSiblingTurnsResponse;
|
||||
|
||||
@@ -13,6 +13,29 @@ use serde::de::Deserializer;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
pub struct ListCredentialRoutesResponse {
|
||||
pub routes: Vec<ResolvedCredentialRoute>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
pub struct ResolvedCredentialRoute {
|
||||
pub connector_id: String,
|
||||
pub link_id: String,
|
||||
pub auth_type: CredentialRouteAuthType,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||||
pub enum CredentialRouteAuthType {
|
||||
#[serde(rename = "API_KEY")]
|
||||
ApiKey,
|
||||
#[serde(rename = "OAUTH")]
|
||||
OAuth,
|
||||
#[serde(rename = "NONE")]
|
||||
None,
|
||||
}
|
||||
|
||||
/// Hand-rolled models for the Cloud Tasks task-details response.
|
||||
/// The generated OpenAPI models are pretty bad. This is a half-step
|
||||
/// towards hand-rolling them.
|
||||
|
||||
@@ -28,6 +28,7 @@ codex-utils-path = { workspace = true }
|
||||
dunce = { workspace = true }
|
||||
futures = { workspace = true, features = ["alloc", "std"] }
|
||||
gethostname = { workspace = true }
|
||||
indexmap = { workspace = true, features = ["serde"] }
|
||||
multimap = { workspace = true }
|
||||
prost = "0.14.3"
|
||||
schemars = { workspace = true }
|
||||
@@ -38,7 +39,7 @@ serde_path_to_error = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs"] }
|
||||
toml = { workspace = true }
|
||||
toml = { workspace = true, features = ["preserve_order"] }
|
||||
toml_edit = { workspace = true }
|
||||
tonic = { workspace = true }
|
||||
tonic-prost = { workspace = true }
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use codex_network_proxy::InjectedHeaderConfig;
|
||||
use codex_network_proxy::MitmHookActionsConfig;
|
||||
use codex_network_proxy::MitmHookBodyConfig;
|
||||
use codex_network_proxy::MitmHookConfig;
|
||||
use codex_network_proxy::MitmHookMatchConfig;
|
||||
use codex_network_proxy::NetworkDomainPermission as ProxyNetworkDomainPermission;
|
||||
use codex_network_proxy::NetworkMode;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission;
|
||||
use codex_network_proxy::normalize_host;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use indexmap::IndexMap;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
@@ -173,6 +179,38 @@ pub struct NetworkToml {
|
||||
pub domains: Option<NetworkDomainPermissionsToml>,
|
||||
pub unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
|
||||
pub allow_local_binding: Option<bool>,
|
||||
pub mitm: Option<NetworkMitmToml>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct NetworkMitmToml {
|
||||
#[schemars(with = "Option<BTreeMap<String, NetworkMitmHookToml>>")]
|
||||
pub hooks: Option<IndexMap<String, NetworkMitmHookToml>>,
|
||||
#[schemars(with = "Option<BTreeMap<String, NetworkMitmActionToml>>")]
|
||||
pub actions: Option<IndexMap<String, NetworkMitmActionToml>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct NetworkMitmTomlUnchecked {
|
||||
pub hooks: Option<IndexMap<String, NetworkMitmHookToml>>,
|
||||
pub actions: Option<IndexMap<String, NetworkMitmActionToml>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct NetworkMitmHookToml {
|
||||
pub host: String,
|
||||
pub methods: Vec<String>,
|
||||
pub path_prefixes: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub query: BTreeMap<String, Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub headers: BTreeMap<String, Vec<String>>,
|
||||
#[schemars(with = "Option<MitmHookBodyConfigSchema>")]
|
||||
pub body: Option<MitmHookBodyConfig>,
|
||||
pub action: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
@@ -182,6 +220,114 @@ enum NetworkModeSchema {
|
||||
Full,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(default)]
|
||||
pub struct NetworkMitmActionToml {
|
||||
pub strip_request_headers: Vec<String>,
|
||||
pub inject_request_headers: Vec<NetworkMitmInjectedHeaderToml>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(default)]
|
||||
pub struct NetworkMitmInjectedHeaderToml {
|
||||
pub name: String,
|
||||
pub secret_env_var: Option<String>,
|
||||
pub secret_file: Option<String>,
|
||||
pub prefix: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(transparent)]
|
||||
struct MitmHookBodyConfigSchema(pub serde_json::Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for NetworkMitmToml {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let unchecked = NetworkMitmTomlUnchecked::deserialize(deserializer)?;
|
||||
let mitm = Self {
|
||||
hooks: unchecked.hooks,
|
||||
actions: unchecked.actions,
|
||||
};
|
||||
mitm.validate_action_definitions()
|
||||
.map_err(serde::de::Error::custom)?;
|
||||
Ok(mitm)
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkMitmToml {
|
||||
pub fn validate_action_definitions(&self) -> Result<(), String> {
|
||||
if let Some(actions) = self.actions.as_ref() {
|
||||
for (action_name, action) in actions {
|
||||
if action.is_empty() {
|
||||
return Err(format!(
|
||||
"network.mitm.actions.{action_name} must define at least one operation"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(hooks) = self.hooks.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
for (hook_name, hook) in hooks {
|
||||
if hook.action.is_empty() {
|
||||
return Err(format!(
|
||||
"network.mitm.hooks.{hook_name}.action must not be empty"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_action_references(
|
||||
&self,
|
||||
actions_by_name: &IndexMap<String, NetworkMitmActionToml>,
|
||||
) -> Result<(), String> {
|
||||
self.validate_action_definitions()?;
|
||||
|
||||
let Some(hooks) = self.hooks.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
for (hook_name, hook) in hooks {
|
||||
for action_name in &hook.action {
|
||||
if !actions_by_name.contains_key(action_name) {
|
||||
return Err(format!(
|
||||
"network.mitm.hooks.{hook_name}.action references undefined action `{action_name}`"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn to_runtime_hooks(
|
||||
&self,
|
||||
actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,
|
||||
) -> Vec<MitmHookConfig> {
|
||||
self.hooks
|
||||
.as_ref()
|
||||
.map(|hooks| {
|
||||
hooks
|
||||
.values()
|
||||
.map(|hook| hook.to_runtime(actions_by_name))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkMitmActionToml {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.strip_request_headers.is_empty() && self.inject_request_headers.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkToml {
|
||||
pub fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) {
|
||||
if let Some(enabled) = self.enabled {
|
||||
@@ -234,6 +380,11 @@ impl NetworkToml {
|
||||
if let Some(allow_local_binding) = self.allow_local_binding {
|
||||
config.network.allow_local_binding = allow_local_binding;
|
||||
}
|
||||
if let Some(mitm) = self.mitm.as_ref() {
|
||||
config.network.mitm_hooks = mitm.to_runtime_hooks(mitm.actions.as_ref());
|
||||
}
|
||||
config.network.mitm =
|
||||
config.network.mode == NetworkMode::Limited || !config.network.mitm_hooks.is_empty();
|
||||
}
|
||||
|
||||
pub fn to_network_proxy_config(&self) -> NetworkProxyConfig {
|
||||
@@ -243,6 +394,61 @@ impl NetworkToml {
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkMitmHookToml {
|
||||
fn to_runtime(
|
||||
&self,
|
||||
actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,
|
||||
) -> MitmHookConfig {
|
||||
MitmHookConfig {
|
||||
host: self.host.clone(),
|
||||
matcher: MitmHookMatchConfig {
|
||||
methods: self.methods.clone(),
|
||||
path_prefixes: self.path_prefixes.clone(),
|
||||
query: self.query.clone(),
|
||||
headers: self.headers.clone(),
|
||||
body: self.body.clone(),
|
||||
},
|
||||
actions: self.selected_actions(actions_by_name),
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_actions(
|
||||
&self,
|
||||
actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,
|
||||
) -> MitmHookActionsConfig {
|
||||
let Some(actions_by_name) = actions_by_name else {
|
||||
return MitmHookActionsConfig::default();
|
||||
};
|
||||
|
||||
let mut selected = MitmHookActionsConfig::default();
|
||||
for action_name in &self.action {
|
||||
if let Some(action) = actions_by_name.get(action_name) {
|
||||
selected
|
||||
.strip_request_headers
|
||||
.extend(action.strip_request_headers.clone());
|
||||
selected.inject_request_headers.extend(
|
||||
action
|
||||
.inject_request_headers
|
||||
.iter()
|
||||
.map(NetworkMitmInjectedHeaderToml::to_runtime),
|
||||
);
|
||||
}
|
||||
}
|
||||
selected
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkMitmInjectedHeaderToml {
|
||||
fn to_runtime(&self) -> InjectedHeaderConfig {
|
||||
InjectedHeaderConfig {
|
||||
name: self.name.clone(),
|
||||
secret_env_var: self.secret_env_var.clone(),
|
||||
secret_file: self.secret_file.clone(),
|
||||
prefix: self.prefix.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn overlay_network_domain_permissions(
|
||||
config: &mut NetworkProxyConfig,
|
||||
domains: &NetworkDomainPermissionsToml,
|
||||
|
||||
@@ -29,6 +29,7 @@ codex-api = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-apply-patch = { workspace = true }
|
||||
codex-async-utils = { workspace = true }
|
||||
codex-backend-client = { workspace = true }
|
||||
codex-code-mode = { workspace = true }
|
||||
codex-connectors = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
|
||||
@@ -1535,6 +1535,118 @@
|
||||
"NetworkDomainPermissionsToml": {
|
||||
"type": "object"
|
||||
},
|
||||
"NetworkMitmActionToml": {
|
||||
"properties": {
|
||||
"inject_request_headers": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/NetworkMitmInjectedHeaderToml"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"strip_request_headers": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"NetworkMitmHookToml": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"action": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"body": true,
|
||||
"headers": {
|
||||
"additionalProperties": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"default": {},
|
||||
"type": "object"
|
||||
},
|
||||
"host": {
|
||||
"type": "string"
|
||||
},
|
||||
"methods": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"path_prefixes": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"query": {
|
||||
"additionalProperties": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"default": {},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"action",
|
||||
"host",
|
||||
"methods",
|
||||
"path_prefixes"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NetworkMitmInjectedHeaderToml": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
"prefix": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
},
|
||||
"secret_env_var": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
},
|
||||
"secret_file": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"NetworkMitmToml": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"actions": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/NetworkMitmActionToml"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"hooks": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/NetworkMitmHookToml"
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"NetworkModeSchema": {
|
||||
"enum": [
|
||||
"limited",
|
||||
@@ -1638,6 +1750,9 @@
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"mitm": {
|
||||
"$ref": "#/definitions/NetworkMitmToml"
|
||||
},
|
||||
"mode": {
|
||||
"$ref": "#/definitions/NetworkModeSchema"
|
||||
},
|
||||
|
||||
@@ -26,6 +26,9 @@ use codex_config::permissions_toml::FilesystemPermissionToml;
|
||||
use codex_config::permissions_toml::FilesystemPermissionsToml;
|
||||
use codex_config::permissions_toml::NetworkDomainPermissionToml;
|
||||
use codex_config::permissions_toml::NetworkDomainPermissionsToml;
|
||||
use codex_config::permissions_toml::NetworkMitmActionToml;
|
||||
use codex_config::permissions_toml::NetworkMitmHookToml;
|
||||
use codex_config::permissions_toml::NetworkMitmToml;
|
||||
use codex_config::permissions_toml::NetworkToml;
|
||||
use codex_config::permissions_toml::PermissionProfileToml;
|
||||
use codex_config::permissions_toml::PermissionsToml;
|
||||
@@ -69,6 +72,7 @@ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
|
||||
use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID;
|
||||
use codex_model_provider_info::WireApi;
|
||||
use codex_models_manager::bundled_models_response;
|
||||
use codex_network_proxy::NetworkMode;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::models::ActivePermissionProfile;
|
||||
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
|
||||
@@ -94,6 +98,7 @@ use core_test_support::PathBufExt;
|
||||
use core_test_support::PathExt;
|
||||
use core_test_support::TempDirExt;
|
||||
use core_test_support::test_absolute_path;
|
||||
use indexmap::IndexMap;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::model::ElicitationCapability;
|
||||
use rmcp::model::FormElicitationCapability;
|
||||
@@ -750,9 +755,19 @@ enabled = true
|
||||
proxy_url = "http://127.0.0.1:43128"
|
||||
enable_socks5 = false
|
||||
allow_upstream_proxy = false
|
||||
mode = "full"
|
||||
|
||||
[permissions.workspace.network.domains]
|
||||
"openai.com" = "allow"
|
||||
|
||||
[permissions.workspace.network.mitm.hooks.github_write]
|
||||
host = "api.github.com"
|
||||
methods = ["POST", "PUT"]
|
||||
path_prefixes = ["/repos/openai/"]
|
||||
action = ["strip_auth"]
|
||||
|
||||
[permissions.workspace.network.mitm.actions.strip_auth]
|
||||
strip_request_headers = ["authorization"]
|
||||
"#;
|
||||
let cfg: ConfigToml =
|
||||
toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles");
|
||||
@@ -795,7 +810,7 @@ allow_upstream_proxy = false
|
||||
allow_upstream_proxy: Some(false),
|
||||
dangerously_allow_non_loopback_proxy: None,
|
||||
dangerously_allow_all_unix_sockets: None,
|
||||
mode: None,
|
||||
mode: Some(NetworkMode::Full),
|
||||
domains: Some(NetworkDomainPermissionsToml {
|
||||
entries: BTreeMap::from([(
|
||||
"openai.com".to_string(),
|
||||
@@ -804,6 +819,27 @@ allow_upstream_proxy = false
|
||||
}),
|
||||
unix_sockets: None,
|
||||
allow_local_binding: None,
|
||||
mitm: Some(NetworkMitmToml {
|
||||
hooks: Some(IndexMap::from([(
|
||||
"github_write".to_string(),
|
||||
NetworkMitmHookToml {
|
||||
host: "api.github.com".to_string(),
|
||||
methods: vec!["POST".to_string(), "PUT".to_string()],
|
||||
path_prefixes: vec!["/repos/openai/".to_string()],
|
||||
query: BTreeMap::new(),
|
||||
headers: BTreeMap::new(),
|
||||
body: None,
|
||||
action: vec!["strip_auth".to_string()],
|
||||
},
|
||||
)])),
|
||||
actions: Some(IndexMap::from([(
|
||||
"strip_auth".to_string(),
|
||||
NetworkMitmActionToml {
|
||||
strip_request_headers: vec!["authorization".to_string()],
|
||||
inject_request_headers: Vec::new(),
|
||||
},
|
||||
)])),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
)]),
|
||||
@@ -811,6 +847,140 @@ allow_upstream_proxy = false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_toml_rejects_empty_mitm_action_reference_list() {
|
||||
let toml = r#"
|
||||
default_permissions = "workspace"
|
||||
|
||||
[permissions.workspace.network.mitm.hooks.github_write]
|
||||
host = "api.github.com"
|
||||
methods = ["POST"]
|
||||
path_prefixes = ["/repos/openai/"]
|
||||
action = []
|
||||
|
||||
[permissions.workspace.network.mitm.actions.strip_auth]
|
||||
strip_request_headers = ["authorization"]
|
||||
"#;
|
||||
|
||||
let err =
|
||||
toml::from_str::<ConfigToml>(toml).expect_err("empty MITM action refs should fail closed");
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("network.mitm.hooks.github_write.action must not be empty"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_toml_rejects_empty_mitm_action_definition() {
|
||||
let toml = r#"
|
||||
default_permissions = "workspace"
|
||||
|
||||
[permissions.workspace.network.mitm.hooks.github_write]
|
||||
host = "api.github.com"
|
||||
methods = ["POST"]
|
||||
path_prefixes = ["/repos/openai/"]
|
||||
action = ["strip_auth"]
|
||||
|
||||
[permissions.workspace.network.mitm.actions.strip_auth]
|
||||
"#;
|
||||
|
||||
let err = toml::from_str::<ConfigToml>(toml)
|
||||
.expect_err("empty MITM action definitions should fail closed");
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("network.mitm.actions.strip_auth must define at least one operation"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permissions_profile_network_to_proxy_config_preserves_mitm_hooks() {
|
||||
let network = NetworkToml {
|
||||
mode: Some(NetworkMode::Full),
|
||||
mitm: Some(NetworkMitmToml {
|
||||
hooks: Some(IndexMap::from([(
|
||||
"github_write".to_string(),
|
||||
NetworkMitmHookToml {
|
||||
host: "api.github.com".to_string(),
|
||||
methods: vec!["POST".to_string()],
|
||||
path_prefixes: vec!["/repos/openai/".to_string()],
|
||||
action: vec!["strip_auth".to_string()],
|
||||
..NetworkMitmHookToml::default()
|
||||
},
|
||||
)])),
|
||||
actions: Some(IndexMap::from([(
|
||||
"strip_auth".to_string(),
|
||||
NetworkMitmActionToml {
|
||||
strip_request_headers: vec!["authorization".to_string()],
|
||||
inject_request_headers: Vec::new(),
|
||||
},
|
||||
)])),
|
||||
}),
|
||||
..NetworkToml::default()
|
||||
};
|
||||
|
||||
let config = network.to_network_proxy_config();
|
||||
|
||||
assert_eq!(config.network.mode, NetworkMode::Full);
|
||||
assert!(config.network.mitm);
|
||||
assert_eq!(config.network.mitm_hooks.len(), 1);
|
||||
assert_eq!(config.network.mitm_hooks[0].host, "api.github.com");
|
||||
assert_eq!(
|
||||
config.network.mitm_hooks[0].matcher.methods,
|
||||
vec!["POST".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
config.network.mitm_hooks[0].actions.strip_request_headers,
|
||||
vec!["authorization".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permissions_profile_network_to_proxy_config_preserves_mitm_hook_declaration_order() {
|
||||
let toml = r#"
|
||||
default_permissions = "workspace"
|
||||
|
||||
[permissions.workspace.network.mitm.actions.noop]
|
||||
strip_request_headers = ["authorization"]
|
||||
|
||||
[permissions.workspace.network.mitm.hooks.z_first]
|
||||
host = "api.github.com"
|
||||
methods = ["POST"]
|
||||
path_prefixes = ["/repos/openai/"]
|
||||
action = ["noop"]
|
||||
|
||||
[permissions.workspace.network.mitm.hooks.a_second]
|
||||
host = "api.github.com"
|
||||
methods = ["POST"]
|
||||
path_prefixes = ["/repos/"]
|
||||
action = ["noop"]
|
||||
"#;
|
||||
let cfg: ConfigToml = toml::from_str(toml).expect("permissions profile should deserialize");
|
||||
let permissions = cfg.permissions.expect("permissions should deserialize");
|
||||
let network = permissions
|
||||
.entries
|
||||
.get("workspace")
|
||||
.expect("workspace profile should exist")
|
||||
.network
|
||||
.as_ref()
|
||||
.expect("network profile should exist");
|
||||
|
||||
let config = network.to_network_proxy_config();
|
||||
|
||||
assert_eq!(config.network.mitm_hooks.len(), 2);
|
||||
assert_eq!(
|
||||
config.network.mitm_hooks[0].matcher.path_prefixes,
|
||||
vec!["/repos/openai/".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
config.network.mitm_hooks[1].matcher.path_prefixes,
|
||||
vec!["/repos/".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn permissions_profiles_proxy_policy_does_not_start_managed_network_proxy_without_feature()
|
||||
-> std::io::Result<()> {
|
||||
|
||||
@@ -177,6 +177,37 @@ impl NetworkProxySpec {
|
||||
Ok(spec)
|
||||
}
|
||||
|
||||
pub(crate) fn with_credentialed_routes(
|
||||
&self,
|
||||
credentialed_routes: &crate::credentialed_routes::CredentialedRoutesSessionConfig,
|
||||
) -> std::io::Result<Self> {
|
||||
let mut spec = self.clone();
|
||||
let credentialed_route_hooks = credentialed_routes.mitm_hooks();
|
||||
let mut allowed_domains = spec.config.network.allowed_domains().unwrap_or_default();
|
||||
for hook in &credentialed_route_hooks {
|
||||
if !allowed_domains
|
||||
.iter()
|
||||
.any(|allowed_domain| normalize_host(allowed_domain) == normalize_host(&hook.host))
|
||||
{
|
||||
allowed_domains.push(hook.host.clone());
|
||||
}
|
||||
}
|
||||
spec.config.network.set_allowed_domains(allowed_domains);
|
||||
let mut mitm_hooks = credentialed_route_hooks;
|
||||
mitm_hooks.extend(spec.config.network.mitm_hooks);
|
||||
spec.config.network.mitm_hooks = mitm_hooks;
|
||||
spec.config.network.mitm = spec.config.network.mode
|
||||
== codex_network_proxy::NetworkMode::Limited
|
||||
|| !spec.config.network.mitm_hooks.is_empty();
|
||||
validate_policy_against_constraints(&spec.config, &spec.constraints).map_err(|err| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("network proxy constraints are invalid: {err}"),
|
||||
)
|
||||
})?;
|
||||
Ok(spec)
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_to_started_proxy(
|
||||
&self,
|
||||
started_proxy: &StartedNetworkProxy,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::*;
|
||||
use codex_backend_client::CredentialRouteAuthType;
|
||||
use codex_backend_client::ResolvedCredentialRoute;
|
||||
use codex_config::NetworkDomainPermissionToml;
|
||||
use codex_config::NetworkDomainPermissionsToml;
|
||||
use codex_network_proxy::NetworkDomainPermission;
|
||||
@@ -80,6 +82,38 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() {
|
||||
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentialed_routes_add_runtime_allowlist_and_mitm_hooks() {
|
||||
let spec = NetworkProxySpec::from_config_and_constraints(
|
||||
NetworkProxyConfig::default(),
|
||||
/*requirements*/ None,
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
|
||||
)
|
||||
.expect("config should load");
|
||||
let credentialed_routes = crate::credentialed_routes::CredentialedRoutesSessionConfig {
|
||||
routes: vec![ResolvedCredentialRoute {
|
||||
connector_id: "connector_123".to_string(),
|
||||
link_id: "link_123".to_string(),
|
||||
auth_type: CredentialRouteAuthType::OAuth,
|
||||
base_url: "https://api.example.com/v1".to_string(),
|
||||
}],
|
||||
proxy_headers: Vec::new(),
|
||||
proxy_url: Some("https://chatgpt.com/backend-api/wham/credential_routes/proxy".to_string()),
|
||||
};
|
||||
|
||||
let spec = spec
|
||||
.with_credentialed_routes(&credentialed_routes)
|
||||
.expect("credentialed routes should fit unconstrained config");
|
||||
|
||||
assert_eq!(
|
||||
spec.config.network.allowed_domains(),
|
||||
Some(vec!["api.example.com".to_string()])
|
||||
);
|
||||
assert!(spec.config.network.mitm);
|
||||
assert_eq!(spec.config.network.mitm_hooks.len(), 1);
|
||||
assert_eq!(spec.config.network.mitm_hooks[0].host, "api.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requirements_allowed_domains_do_not_override_user_denies_for_same_pattern() {
|
||||
let mut config = NetworkProxyConfig::default();
|
||||
|
||||
@@ -185,6 +185,7 @@ pub(crate) fn apply_network_proxy_feature_config(
|
||||
}
|
||||
}),
|
||||
allow_local_binding: feature_config.allow_local_binding,
|
||||
mitm: None,
|
||||
}
|
||||
.apply_to_network_proxy_config(config);
|
||||
}
|
||||
|
||||
248
codex-rs/core/src/credentialed_routes.rs
Normal file
248
codex-rs/core/src/credentialed_routes.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
use codex_backend_client::ResolvedCredentialRoute;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_network_proxy::CredentialedRouteProxyActionConfig;
|
||||
use codex_network_proxy::CredentialedRouteProxyHeader;
|
||||
use codex_network_proxy::MitmHookActionsConfig;
|
||||
use codex_network_proxy::MitmHookConfig;
|
||||
use codex_network_proxy::MitmHookMatchConfig;
|
||||
use http::HeaderMap;
|
||||
use std::collections::BTreeSet;
|
||||
use tracing::debug;
|
||||
use tracing::warn;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct CredentialedRoutesSessionConfig {
|
||||
pub(crate) routes: Vec<ResolvedCredentialRoute>,
|
||||
pub(crate) proxy_headers: Vec<CredentialedRouteProxyHeader>,
|
||||
pub(crate) proxy_url: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn load_for_session(
|
||||
chatgpt_base_url: &str,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> CredentialedRoutesSessionConfig {
|
||||
let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) else {
|
||||
return CredentialedRoutesSessionConfig::default();
|
||||
};
|
||||
|
||||
let client = match BackendClient::from_auth(chatgpt_base_url.to_string(), auth) {
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to initialize credentialed routes client");
|
||||
return CredentialedRoutesSessionConfig::default();
|
||||
}
|
||||
};
|
||||
|
||||
match client.list_credential_routes().await {
|
||||
Ok(response) => {
|
||||
debug!(
|
||||
credentialed_routes = response.routes.len(),
|
||||
"loaded credentialed routes for session"
|
||||
);
|
||||
CredentialedRoutesSessionConfig {
|
||||
routes: response.routes,
|
||||
proxy_headers: credentialed_route_proxy_headers(
|
||||
client.credential_routes_proxy_auth_headers(),
|
||||
),
|
||||
proxy_url: Some(client.credential_routes_proxy_url()),
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to load credentialed routes for session");
|
||||
CredentialedRoutesSessionConfig::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CredentialedRoutesSessionConfig {
|
||||
pub(crate) fn mitm_hooks(&self) -> Vec<MitmHookConfig> {
|
||||
let Some(proxy_url) = self.proxy_url.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
self.routes
|
||||
.iter()
|
||||
.filter_map(
|
||||
|route| match route_mitm_hook(route, &self.proxy_headers, proxy_url) {
|
||||
Ok(hook) => Some(hook),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
connector_id = %route.connector_id,
|
||||
link_id = %route.link_id,
|
||||
base_url = %route.base_url,
|
||||
error = %err,
|
||||
"skipping invalid credentialed route"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn developer_instructions(&self) -> Option<String> {
|
||||
let route_prefixes = self
|
||||
.routes
|
||||
.iter()
|
||||
.map(|route| route.base_url.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
if route_prefixes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let route_prefixes = route_prefixes
|
||||
.into_iter()
|
||||
.map(|route_prefix| format!("- {route_prefix}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
Some(format!(
|
||||
"The managed network proxy automatically attaches stored credentials when you call these HTTPS URL prefixes directly:\n{route_prefixes}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn route_mitm_hook(
|
||||
route: &ResolvedCredentialRoute,
|
||||
proxy_headers: &[CredentialedRouteProxyHeader],
|
||||
proxy_url: &str,
|
||||
) -> anyhow::Result<MitmHookConfig> {
|
||||
let base_url = Url::parse(&route.base_url)?;
|
||||
anyhow::ensure!(
|
||||
base_url.scheme() == "https",
|
||||
"credentialed route must use https"
|
||||
);
|
||||
let host = base_url
|
||||
.host_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("credentialed route must include a host"))?;
|
||||
anyhow::ensure!(
|
||||
base_url.username().is_empty() && base_url.password().is_none(),
|
||||
"credentialed route must not include user info"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
base_url.fragment().is_none() && base_url.query().is_none(),
|
||||
"credentialed route must not include query or fragment"
|
||||
);
|
||||
let path_prefix = match base_url.path() {
|
||||
"" => "/",
|
||||
path => path,
|
||||
};
|
||||
|
||||
Ok(MitmHookConfig {
|
||||
host: host.to_string(),
|
||||
matcher: MitmHookMatchConfig {
|
||||
methods: vec![
|
||||
"DELETE".to_string(),
|
||||
"GET".to_string(),
|
||||
"HEAD".to_string(),
|
||||
"PATCH".to_string(),
|
||||
"POST".to_string(),
|
||||
"PUT".to_string(),
|
||||
],
|
||||
path_prefixes: vec![path_prefix.to_string()],
|
||||
..MitmHookMatchConfig::default()
|
||||
},
|
||||
actions: MitmHookActionsConfig {
|
||||
credentialed_route_proxy: Some(CredentialedRouteProxyActionConfig {
|
||||
connector_id: route.connector_id.clone(),
|
||||
link_id: route.link_id.clone(),
|
||||
proxy_headers: proxy_headers.to_vec(),
|
||||
proxy_url: proxy_url.to_string(),
|
||||
}),
|
||||
..MitmHookActionsConfig::default()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn credentialed_route_proxy_headers(headers: HeaderMap) -> Vec<CredentialedRouteProxyHeader> {
|
||||
headers
|
||||
.iter()
|
||||
.map(|(name, value)| CredentialedRouteProxyHeader {
|
||||
name: name.clone(),
|
||||
value: value.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_backend_client::CredentialRouteAuthType;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn credentialed_routes_compile_to_internal_mitm_hooks() {
|
||||
let config = CredentialedRoutesSessionConfig {
|
||||
routes: vec![ResolvedCredentialRoute {
|
||||
connector_id: "connector_123".to_string(),
|
||||
link_id: "link_123".to_string(),
|
||||
auth_type: CredentialRouteAuthType::OAuth,
|
||||
base_url: "https://api.example.com/v1".to_string(),
|
||||
}],
|
||||
proxy_headers: vec![CredentialedRouteProxyHeader {
|
||||
name: http::header::AUTHORIZATION,
|
||||
value: http::HeaderValue::from_static("Bearer codex-token"),
|
||||
}],
|
||||
proxy_url: Some(
|
||||
"https://chatgpt.com/backend-api/wham/credential_routes/proxy".to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let hooks = config.mitm_hooks();
|
||||
|
||||
assert_eq!(hooks.len(), 1);
|
||||
assert_eq!(hooks[0].host, "api.example.com");
|
||||
assert_eq!(hooks[0].matcher.path_prefixes, vec!["/v1".to_string()]);
|
||||
assert_eq!(
|
||||
hooks[0].actions.credentialed_route_proxy,
|
||||
Some(CredentialedRouteProxyActionConfig {
|
||||
connector_id: "connector_123".to_string(),
|
||||
link_id: "link_123".to_string(),
|
||||
proxy_headers: vec![CredentialedRouteProxyHeader {
|
||||
name: http::header::AUTHORIZATION,
|
||||
value: http::HeaderValue::from_static("Bearer codex-token"),
|
||||
}],
|
||||
proxy_url: "https://chatgpt.com/backend-api/wham/credential_routes/proxy"
|
||||
.to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentialed_routes_render_model_visible_prefixes() {
|
||||
let config = CredentialedRoutesSessionConfig {
|
||||
routes: vec![
|
||||
ResolvedCredentialRoute {
|
||||
connector_id: "connector_b".to_string(),
|
||||
link_id: "link_b".to_string(),
|
||||
auth_type: CredentialRouteAuthType::OAuth,
|
||||
base_url: "https://b.example.com/v1".to_string(),
|
||||
},
|
||||
ResolvedCredentialRoute {
|
||||
connector_id: "connector_a".to_string(),
|
||||
link_id: "link_a".to_string(),
|
||||
auth_type: CredentialRouteAuthType::OAuth,
|
||||
base_url: "https://a.example.com/v1".to_string(),
|
||||
},
|
||||
ResolvedCredentialRoute {
|
||||
connector_id: "connector_a".to_string(),
|
||||
link_id: "link_a".to_string(),
|
||||
auth_type: CredentialRouteAuthType::OAuth,
|
||||
base_url: "https://a.example.com/v1".to_string(),
|
||||
},
|
||||
],
|
||||
proxy_headers: Vec::new(),
|
||||
proxy_url: Some(
|
||||
"https://chatgpt.com/backend-api/wham/credential_routes/proxy".to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
config.developer_instructions(),
|
||||
Some(
|
||||
"The managed network proxy automatically attaches stored credentials when you call these HTTPS URL prefixes directly:\n- https://a.example.com/v1\n- https://b.example.com/v1".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ mod codex_thread;
|
||||
mod compact_remote;
|
||||
mod compact_remote_v2;
|
||||
mod config_lock;
|
||||
mod credentialed_routes;
|
||||
pub use codex_thread::CodexThread;
|
||||
pub use codex_thread::CodexThreadTurnContextOverrides;
|
||||
pub use codex_thread::ThreadConfigSnapshot;
|
||||
|
||||
@@ -13,12 +13,16 @@ use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigLayerStackOrdering;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::loader::load_config_layers_state;
|
||||
use codex_config::permissions_toml::NetworkMitmActionToml;
|
||||
use codex_config::permissions_toml::NetworkMitmHookToml;
|
||||
use codex_config::permissions_toml::NetworkMitmToml;
|
||||
use codex_config::permissions_toml::NetworkToml;
|
||||
use codex_config::permissions_toml::PermissionsToml;
|
||||
use codex_config::permissions_toml::overlay_network_domain_permissions;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_network_proxy::ConfigReloader;
|
||||
use codex_network_proxy::ConfigState;
|
||||
use codex_network_proxy::NetworkMode;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
use codex_network_proxy::NetworkProxyConstraintError;
|
||||
use codex_network_proxy::NetworkProxyConstraints;
|
||||
@@ -27,6 +31,7 @@ use codex_network_proxy::build_config_state;
|
||||
use codex_network_proxy::normalize_host;
|
||||
use codex_network_proxy::validate_policy_against_constraints;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use indexmap::IndexMap;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
@@ -198,6 +203,7 @@ fn selected_network_from_tables(parsed: NetworkTablesToml) -> Result<Option<Netw
|
||||
Ok(profile.network.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesToml) -> Result<()> {
|
||||
if let Some(network) = selected_network_from_tables(parsed)? {
|
||||
network.apply_to_network_proxy_config(config);
|
||||
@@ -205,18 +211,66 @@ fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesTo
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct NetworkConfigAccumulator {
|
||||
config: NetworkProxyConfig,
|
||||
mitm_hooks: IndexMap<String, NetworkMitmHookToml>,
|
||||
mitm_actions: IndexMap<String, NetworkMitmActionToml>,
|
||||
}
|
||||
|
||||
impl NetworkConfigAccumulator {
|
||||
fn apply_network_tables(&mut self, parsed: NetworkTablesToml) -> Result<()> {
|
||||
if let Some(network) = selected_network_from_tables(parsed)? {
|
||||
self.apply_network(network);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_network(&mut self, mut network: NetworkToml) {
|
||||
let mitm = network.mitm.take();
|
||||
network.apply_to_network_proxy_config(&mut self.config);
|
||||
|
||||
if let Some(mitm) = mitm {
|
||||
if let Some(actions) = mitm.actions {
|
||||
self.mitm_actions.extend(actions);
|
||||
}
|
||||
if let Some(hooks) = mitm.hooks {
|
||||
self.mitm_hooks.extend(hooks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(mut self) -> Result<NetworkProxyConfig> {
|
||||
if !self.mitm_hooks.is_empty() {
|
||||
let actions = self.mitm_actions;
|
||||
let mitm = NetworkMitmToml {
|
||||
hooks: Some(self.mitm_hooks),
|
||||
actions: Some(actions.clone()),
|
||||
};
|
||||
mitm.validate_action_references(&actions)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
self.config.network.mitm_hooks = mitm.to_runtime_hooks(Some(&actions));
|
||||
}
|
||||
|
||||
self.config.network.mitm = self.config.network.mode == NetworkMode::Limited
|
||||
|| !self.config.network.mitm_hooks.is_empty();
|
||||
Ok(self.config)
|
||||
}
|
||||
}
|
||||
|
||||
fn config_from_layers(
|
||||
layers: &ConfigLayerStack,
|
||||
exec_policy: &codex_execpolicy::Policy,
|
||||
) -> Result<NetworkProxyConfig> {
|
||||
let mut config = NetworkProxyConfig::default();
|
||||
let mut accumulator = NetworkConfigAccumulator::default();
|
||||
for layer in layers.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ false,
|
||||
) {
|
||||
let parsed = network_tables_from_toml(&layer.config)?;
|
||||
apply_network_tables(&mut config, parsed)?;
|
||||
accumulator.apply_network_tables(parsed)?;
|
||||
}
|
||||
let mut config = accumulator.finish()?;
|
||||
apply_exec_policy_network_rules(&mut config, exec_policy);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
@@ -104,6 +104,76 @@ default_permissions = "workspace"
|
||||
assert_eq!(config.network.denied_domains(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_precedence_profile_network_overrides_named_mitm_actions() {
|
||||
let lower_network: toml::Value = toml::from_str(
|
||||
r#"
|
||||
default_permissions = "workspace"
|
||||
|
||||
[permissions.workspace.network]
|
||||
mode = "full"
|
||||
|
||||
[permissions.workspace.network.domains]
|
||||
"lower.example.com" = "allow"
|
||||
|
||||
[permissions.workspace.network.mitm.hooks.github_write]
|
||||
host = "api.github.com"
|
||||
methods = ["POST"]
|
||||
path_prefixes = ["/repos/openai/"]
|
||||
action = ["strip_auth"]
|
||||
|
||||
[permissions.workspace.network.mitm.actions.strip_auth]
|
||||
strip_request_headers = ["authorization"]
|
||||
"#,
|
||||
)
|
||||
.expect("lower layer should parse");
|
||||
let higher_network: toml::Value = toml::from_str(
|
||||
r#"
|
||||
default_permissions = "workspace"
|
||||
|
||||
[permissions.workspace.network]
|
||||
mode = "full"
|
||||
|
||||
[permissions.workspace.network.domains]
|
||||
"higher.example.com" = "allow"
|
||||
|
||||
[permissions.workspace.network.mitm.actions.strip_auth]
|
||||
strip_request_headers = ["x-api-key"]
|
||||
"#,
|
||||
)
|
||||
.expect("higher layer should parse");
|
||||
|
||||
let mut accumulator = NetworkConfigAccumulator::default();
|
||||
accumulator
|
||||
.apply_network_tables(
|
||||
network_tables_from_toml(&lower_network).expect("lower layer should deserialize"),
|
||||
)
|
||||
.expect("lower layer should apply");
|
||||
accumulator
|
||||
.apply_network_tables(
|
||||
network_tables_from_toml(&higher_network).expect("higher layer should deserialize"),
|
||||
)
|
||||
.expect("higher layer should apply");
|
||||
let config = accumulator.finish().expect("merged config should build");
|
||||
|
||||
assert_eq!(config.network.mode, codex_network_proxy::NetworkMode::Full);
|
||||
assert!(config.network.mitm);
|
||||
assert_eq!(
|
||||
config.network.allowed_domains(),
|
||||
Some(vec![
|
||||
"lower.example.com".to_string(),
|
||||
"higher.example.com".to_string()
|
||||
])
|
||||
);
|
||||
assert_eq!(config.network.mitm_hooks.len(), 1);
|
||||
assert_eq!(config.network.mitm_hooks[0].host, "api.github.com");
|
||||
assert_eq!(config.network.mitm_hooks[0].matcher.methods, vec!["POST"]);
|
||||
assert_eq!(
|
||||
config.network.mitm_hooks[0].actions.strip_request_headers,
|
||||
vec!["x-api-key"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execpolicy_network_rules_overlay_network_lists() {
|
||||
let mut config = NetworkProxyConfig::default();
|
||||
|
||||
@@ -875,6 +875,17 @@ async fn thread_title_from_thread_store(
|
||||
(!title.is_empty() && thread.preview.trim() != title).then(|| title.to_string())
|
||||
}
|
||||
|
||||
struct ManagedNetworkProxyStartParams<'a> {
|
||||
spec: &'a crate::config::NetworkProxySpec,
|
||||
credentialed_routes: &'a crate::credentialed_routes::CredentialedRoutesSessionConfig,
|
||||
exec_policy: &'a codex_execpolicy::Policy,
|
||||
permission_profile: &'a PermissionProfile,
|
||||
network_policy_decider: Option<Arc<dyn codex_network_proxy::NetworkPolicyDecider>>,
|
||||
blocked_request_observer: Option<Arc<dyn codex_network_proxy::BlockedRequestObserver>>,
|
||||
managed_network_requirements_enabled: bool,
|
||||
audit_metadata: NetworkProxyAuditMetadata,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub(crate) async fn app_server_client_metadata(&self) -> AppServerClientMetadata {
|
||||
let state = self.state.lock().await;
|
||||
@@ -938,15 +949,30 @@ impl Session {
|
||||
}
|
||||
|
||||
async fn start_managed_network_proxy(
|
||||
spec: &crate::config::NetworkProxySpec,
|
||||
exec_policy: &codex_execpolicy::Policy,
|
||||
permission_profile: &PermissionProfile,
|
||||
network_policy_decider: Option<Arc<dyn codex_network_proxy::NetworkPolicyDecider>>,
|
||||
blocked_request_observer: Option<Arc<dyn codex_network_proxy::BlockedRequestObserver>>,
|
||||
managed_network_requirements_enabled: bool,
|
||||
audit_metadata: NetworkProxyAuditMetadata,
|
||||
ManagedNetworkProxyStartParams {
|
||||
spec,
|
||||
credentialed_routes,
|
||||
exec_policy,
|
||||
permission_profile,
|
||||
network_policy_decider,
|
||||
blocked_request_observer,
|
||||
managed_network_requirements_enabled,
|
||||
audit_metadata,
|
||||
}: ManagedNetworkProxyStartParams<'_>,
|
||||
) -> anyhow::Result<(StartedNetworkProxy, SessionNetworkProxyRuntime)> {
|
||||
debug!(
|
||||
credentialed_routes = credentialed_routes.routes.len(),
|
||||
"starting managed network proxy"
|
||||
);
|
||||
let spec = spec
|
||||
.with_credentialed_routes(credentialed_routes)
|
||||
.map_err(|err| {
|
||||
tracing::warn!(
|
||||
"failed to apply credentialed routes to managed proxy; continuing without credentialed routes: {err}"
|
||||
);
|
||||
err
|
||||
})
|
||||
.unwrap_or_else(|_| spec.clone())
|
||||
.with_exec_policy_network_rules(exec_policy)
|
||||
.map_err(|err| {
|
||||
tracing::warn!(
|
||||
@@ -1005,6 +1031,15 @@ impl Session {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let spec = match spec.with_credentialed_routes(&self.services.credentialed_routes) {
|
||||
Ok(spec) => spec,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to apply credentialed routes while refreshing managed network proxy: {err}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let current_exec_policy = self.services.exec_policy.current();
|
||||
let spec = match spec.with_exec_policy_network_rules(current_exec_policy.as_ref()) {
|
||||
Ok(spec) => spec,
|
||||
@@ -2680,6 +2715,11 @@ impl Session {
|
||||
.render(),
|
||||
);
|
||||
}
|
||||
if let Some(credentialed_routes_instructions) =
|
||||
self.services.credentialed_routes.developer_instructions()
|
||||
{
|
||||
developer_sections.push(credentialed_routes_instructions);
|
||||
}
|
||||
let separate_guardian_developer_message =
|
||||
crate::guardian::is_guardian_reviewer_source(&session_source);
|
||||
// Keep the guardian policy prompt out of the aggregated developer bundle so it
|
||||
|
||||
@@ -811,17 +811,29 @@ impl Session {
|
||||
Arc::clone(network_policy_decider_session),
|
||||
)
|
||||
});
|
||||
let credentialed_routes = if config.permissions.network.is_some() {
|
||||
crate::credentialed_routes::load_for_session(&config.chatgpt_base_url, auth)
|
||||
.await
|
||||
} else {
|
||||
crate::credentialed_routes::CredentialedRoutesSessionConfig::default()
|
||||
};
|
||||
let (network_proxy, session_network_proxy) =
|
||||
if let Some(spec) = config.permissions.network.as_ref() {
|
||||
let current_exec_policy = exec_policy.current();
|
||||
let (network_proxy, session_network_proxy) = Self::start_managed_network_proxy(
|
||||
spec,
|
||||
current_exec_policy.as_ref(),
|
||||
config.permissions.permission_profile(),
|
||||
network_policy_decider.as_ref().map(Arc::clone),
|
||||
blocked_request_observer.as_ref().map(Arc::clone),
|
||||
managed_network_requirements_configured,
|
||||
network_proxy_audit_metadata,
|
||||
ManagedNetworkProxyStartParams {
|
||||
spec,
|
||||
credentialed_routes: &credentialed_routes,
|
||||
exec_policy: current_exec_policy.as_ref(),
|
||||
permission_profile: config.permissions.permission_profile(),
|
||||
network_policy_decider: network_policy_decider.as_ref().map(Arc::clone),
|
||||
blocked_request_observer: blocked_request_observer
|
||||
.as_ref()
|
||||
.map(Arc::clone),
|
||||
managed_network_requirements_enabled:
|
||||
managed_network_requirements_configured,
|
||||
audit_metadata: network_proxy_audit_metadata,
|
||||
},
|
||||
)
|
||||
.instrument(info_span!(
|
||||
"session_init.network_proxy",
|
||||
@@ -914,6 +926,7 @@ impl Session {
|
||||
thread_extension_data,
|
||||
agent_control,
|
||||
network_proxy,
|
||||
credentialed_routes,
|
||||
network_approval: Arc::clone(&network_approval),
|
||||
state_db: state_db_ctx.clone(),
|
||||
live_thread: live_thread_init.as_ref().cloned(),
|
||||
|
||||
@@ -683,15 +683,19 @@ async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyho
|
||||
/*justification*/ None,
|
||||
)?;
|
||||
|
||||
let (started_proxy, _) = Session::start_managed_network_proxy(
|
||||
&spec,
|
||||
&exec_policy,
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
|
||||
/*network_policy_decider*/ None,
|
||||
/*blocked_request_observer*/ None,
|
||||
/*managed_network_requirements_enabled*/ false,
|
||||
crate::config::NetworkProxyAuditMetadata::default(),
|
||||
)
|
||||
let (started_proxy, _) = Session::start_managed_network_proxy(ManagedNetworkProxyStartParams {
|
||||
spec: &spec,
|
||||
credentialed_routes: &crate::credentialed_routes::CredentialedRoutesSessionConfig::default(
|
||||
),
|
||||
exec_policy: &exec_policy,
|
||||
permission_profile: &permission_profile_for_sandbox_policy(
|
||||
&SandboxPolicy::new_workspace_write_policy(),
|
||||
),
|
||||
network_policy_decider: None,
|
||||
blocked_request_observer: None,
|
||||
managed_network_requirements_enabled: false,
|
||||
audit_metadata: crate::config::NetworkProxyAuditMetadata::default(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let current_cfg = started_proxy.proxy().current_cfg().await?;
|
||||
@@ -727,15 +731,19 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules()
|
||||
/*justification*/ None,
|
||||
)?;
|
||||
|
||||
let (started_proxy, _) = Session::start_managed_network_proxy(
|
||||
&spec,
|
||||
&exec_policy,
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()),
|
||||
/*network_policy_decider*/ None,
|
||||
/*blocked_request_observer*/ None,
|
||||
/*managed_network_requirements_enabled*/ false,
|
||||
crate::config::NetworkProxyAuditMetadata::default(),
|
||||
)
|
||||
let (started_proxy, _) = Session::start_managed_network_proxy(ManagedNetworkProxyStartParams {
|
||||
spec: &spec,
|
||||
credentialed_routes: &crate::credentialed_routes::CredentialedRoutesSessionConfig::default(
|
||||
),
|
||||
exec_policy: &exec_policy,
|
||||
permission_profile: &permission_profile_for_sandbox_policy(
|
||||
&SandboxPolicy::new_workspace_write_policy(),
|
||||
),
|
||||
network_policy_decider: None,
|
||||
blocked_request_observer: None,
|
||||
managed_network_requirements_enabled: false,
|
||||
audit_metadata: crate::config::NetworkProxyAuditMetadata::default(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let current_cfg = started_proxy.proxy().current_cfg().await?;
|
||||
@@ -766,15 +774,19 @@ async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::R
|
||||
}
|
||||
});
|
||||
|
||||
let (started_proxy, _) = Session::start_managed_network_proxy(
|
||||
&spec,
|
||||
&exec_policy,
|
||||
&permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess),
|
||||
Some(network_policy_decider),
|
||||
/*blocked_request_observer*/ None,
|
||||
/*managed_network_requirements_enabled*/ true,
|
||||
crate::config::NetworkProxyAuditMetadata::default(),
|
||||
)
|
||||
let (started_proxy, _) = Session::start_managed_network_proxy(ManagedNetworkProxyStartParams {
|
||||
spec: &spec,
|
||||
credentialed_routes: &crate::credentialed_routes::CredentialedRoutesSessionConfig::default(
|
||||
),
|
||||
exec_policy: &exec_policy,
|
||||
permission_profile: &permission_profile_for_sandbox_policy(
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
),
|
||||
network_policy_decider: Some(network_policy_decider),
|
||||
blocked_request_observer: None,
|
||||
managed_network_requirements_enabled: true,
|
||||
audit_metadata: crate::config::NetworkProxyAuditMetadata::default(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let spec = spec.recompute_for_permission_profile(&permission_profile_for_sandbox_policy(
|
||||
@@ -838,15 +850,17 @@ async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow
|
||||
Some(requirements),
|
||||
&permission_profile_for_sandbox_policy(&initial_policy),
|
||||
)?;
|
||||
let (started_proxy, _) = Session::start_managed_network_proxy(
|
||||
&spec,
|
||||
&Policy::empty(),
|
||||
&permission_profile_for_sandbox_policy(&initial_policy),
|
||||
/*network_policy_decider*/ None,
|
||||
/*blocked_request_observer*/ None,
|
||||
/*managed_network_requirements_enabled*/ false,
|
||||
crate::config::NetworkProxyAuditMetadata::default(),
|
||||
)
|
||||
let (started_proxy, _) = Session::start_managed_network_proxy(ManagedNetworkProxyStartParams {
|
||||
spec: &spec,
|
||||
credentialed_routes: &crate::credentialed_routes::CredentialedRoutesSessionConfig::default(
|
||||
),
|
||||
exec_policy: &Policy::empty(),
|
||||
permission_profile: &permission_profile_for_sandbox_policy(&initial_policy),
|
||||
network_policy_decider: None,
|
||||
blocked_request_observer: None,
|
||||
managed_network_requirements_enabled: false,
|
||||
audit_metadata: crate::config::NetworkProxyAuditMetadata::default(),
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(
|
||||
started_proxy
|
||||
@@ -4342,6 +4356,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()),
|
||||
agent_control,
|
||||
network_proxy: None,
|
||||
credentialed_routes: crate::credentialed_routes::CredentialedRoutesSessionConfig::default(),
|
||||
network_approval: Arc::clone(&network_approval),
|
||||
state_db: None,
|
||||
live_thread: None,
|
||||
@@ -6198,6 +6213,7 @@ where
|
||||
thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()),
|
||||
agent_control,
|
||||
network_proxy: None,
|
||||
credentialed_routes: crate::credentialed_routes::CredentialedRoutesSessionConfig::default(),
|
||||
network_approval: Arc::clone(&network_approval),
|
||||
state_db: state_db.clone(),
|
||||
live_thread: None,
|
||||
|
||||
@@ -66,6 +66,7 @@ pub(crate) struct SessionServices {
|
||||
pub(crate) thread_extension_data: ExtensionData,
|
||||
pub(crate) agent_control: AgentControl,
|
||||
pub(crate) network_proxy: Option<StartedNetworkProxy>,
|
||||
pub(crate) credentialed_routes: crate::credentialed_routes::CredentialedRoutesSessionConfig,
|
||||
pub(crate) network_approval: Arc<NetworkApprovalService>,
|
||||
pub(crate) state_db: Option<StateDbHandle>,
|
||||
pub(crate) live_thread: Option<LiveThread>,
|
||||
|
||||
@@ -15,6 +15,7 @@ workspace = true
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
chrono = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
@@ -35,6 +36,7 @@ rama-net = { version = "=0.3.0-alpha.4", features = ["http", "tls"] }
|
||||
rama-socks5 = { version = "=0.3.0-alpha.4" }
|
||||
rama-tcp = { version = "=0.3.0-alpha.4", features = ["http"] }
|
||||
rama-tls-rustls = { version = "=0.3.0-alpha.4", features = ["http"] }
|
||||
rustls-native-certs = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
|
||||
@@ -33,9 +33,10 @@ allow_upstream_proxy = true
|
||||
# If you want to expose these listeners beyond localhost, you must opt in explicitly.
|
||||
dangerously_allow_non_loopback_proxy = false
|
||||
mode = "full" # default when unset; use "limited" for read-only mode
|
||||
# When true, HTTPS CONNECT can be terminated so limited-mode method policy still applies.
|
||||
mitm = false
|
||||
# HTTPS MITM is enabled automatically when `mode = "limited"` or when MITM hooks are configured.
|
||||
# CA cert/key are managed internally under $CODEX_HOME/proxy/ (ca.pem + ca.key).
|
||||
# When MITM is active, spawned commands receive CA bundle env vars pointing at
|
||||
# $CODEX_HOME/proxy/ca-bundle.pem so common HTTPS clients trust the managed CA.
|
||||
|
||||
# If false, local/private networking is rejected. Explicit allowlisting of local IP literals
|
||||
# (or `localhost`) is required to permit them.
|
||||
@@ -57,6 +58,17 @@ dangerously_allow_all_unix_sockets = false
|
||||
"::1" = "allow"
|
||||
"evil.example" = "deny"
|
||||
|
||||
# MITM hooks match HTTPS requests after CONNECT is terminated.
|
||||
[permissions.workspace.network.mitm.hooks.github_write]
|
||||
host = "api.github.com"
|
||||
methods = ["POST", "PUT"]
|
||||
path_prefixes = ["/repos/openai/"]
|
||||
action = ["strip_auth"]
|
||||
|
||||
# Named actions can be shared across hooks and overridden by higher-precedence config layers.
|
||||
[permissions.workspace.network.mitm.actions.strip_auth]
|
||||
strip_request_headers = ["authorization"]
|
||||
|
||||
# macOS-only: allows proxying to a unix socket when request includes `x-unix-socket: /path`.
|
||||
[permissions.workspace.network.unix_sockets]
|
||||
"/tmp/example.sock" = "allow"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use anyhow::Context as _;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use base64::Engine as _;
|
||||
use codex_utils_home_dir::find_codex_home;
|
||||
use rama_net::tls::ApplicationProtocol;
|
||||
use rama_tls_rustls::dep::pki_types::CertificateDer;
|
||||
@@ -19,6 +20,7 @@ use rama_tls_rustls::dep::rcgen::PKCS_ECDSA_P256_SHA256;
|
||||
use rama_tls_rustls::dep::rcgen::SanType;
|
||||
use rama_tls_rustls::dep::rustls;
|
||||
use rama_tls_rustls::server::TlsAcceptorData;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::fs::OpenOptions;
|
||||
@@ -29,6 +31,7 @@ use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
pub(super) struct ManagedMitmCa {
|
||||
issuer: Issuer<'static, KeyPair>,
|
||||
@@ -95,6 +98,20 @@ fn issue_host_certificate_pem(
|
||||
const MANAGED_MITM_CA_DIR: &str = "proxy";
|
||||
const MANAGED_MITM_CA_CERT: &str = "ca.pem";
|
||||
const MANAGED_MITM_CA_KEY: &str = "ca.key";
|
||||
const MANAGED_MITM_CA_TRUST_BUNDLE: &str = "ca-bundle.pem";
|
||||
|
||||
const CUSTOM_CA_ENV_KEYS: &[&str] = &[
|
||||
"CODEX_CA_CERTIFICATE",
|
||||
"SSL_CERT_FILE",
|
||||
"REQUESTS_CA_BUNDLE",
|
||||
"CURL_CA_BUNDLE",
|
||||
"NODE_EXTRA_CA_CERTS",
|
||||
"GIT_SSL_CAINFO",
|
||||
"PIP_CERT",
|
||||
"BUNDLE_SSL_CA_CERT",
|
||||
"npm_config_cafile",
|
||||
"NPM_CONFIG_CAFILE",
|
||||
];
|
||||
|
||||
fn managed_ca_paths() -> Result<(PathBuf, PathBuf)> {
|
||||
let codex_home =
|
||||
@@ -106,6 +123,85 @@ fn managed_ca_paths() -> Result<(PathBuf, PathBuf)> {
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn managed_ca_trust_bundle_path(env: &HashMap<String, String>) -> Result<PathBuf> {
|
||||
let (cert_path, _) = managed_ca_paths()?;
|
||||
let trust_bundle_path = cert_path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow!("managed MITM CA cert path is missing a parent"))?
|
||||
.join(MANAGED_MITM_CA_TRUST_BUNDLE);
|
||||
let trust_bundle = build_managed_ca_trust_bundle(&cert_path, env)?;
|
||||
write_atomic_replace(
|
||||
&trust_bundle_path,
|
||||
trust_bundle.as_bytes(),
|
||||
/*mode*/ 0o644,
|
||||
)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to persist managed MITM CA trust bundle {}",
|
||||
trust_bundle_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(trust_bundle_path)
|
||||
}
|
||||
|
||||
fn build_managed_ca_trust_bundle(
|
||||
managed_ca_cert_path: &Path,
|
||||
env: &HashMap<String, String>,
|
||||
) -> Result<String> {
|
||||
let mut trust_bundle = String::new();
|
||||
let rustls_native_certs::CertificateResult { certs, errors, .. } =
|
||||
rustls_native_certs::load_native_certs();
|
||||
if !errors.is_empty() {
|
||||
warn!(
|
||||
native_root_error_count = errors.len(),
|
||||
"encountered errors while loading native root certificates for MITM trust bundle"
|
||||
);
|
||||
}
|
||||
for cert in certs {
|
||||
push_certificate_pem(&mut trust_bundle, cert.as_ref());
|
||||
}
|
||||
|
||||
let mut custom_ca_paths = Vec::new();
|
||||
for key in CUSTOM_CA_ENV_KEYS {
|
||||
let Some(path) = env.get(*key).filter(|path| !path.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let path = PathBuf::from(path);
|
||||
if path == managed_ca_cert_path || custom_ca_paths.contains(&path) {
|
||||
continue;
|
||||
}
|
||||
custom_ca_paths.push(path);
|
||||
}
|
||||
for path in custom_ca_paths {
|
||||
append_pem_file(&mut trust_bundle, &path)?;
|
||||
}
|
||||
append_pem_file(&mut trust_bundle, managed_ca_cert_path)?;
|
||||
Ok(trust_bundle)
|
||||
}
|
||||
|
||||
fn append_pem_file(bundle: &mut String, path: &Path) -> Result<()> {
|
||||
if !bundle.ends_with('\n') {
|
||||
bundle.push('\n');
|
||||
}
|
||||
let pem = fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read CA bundle {}", path.display()))?;
|
||||
bundle.push_str(&pem);
|
||||
if !bundle.ends_with('\n') {
|
||||
bundle.push('\n');
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_certificate_pem(bundle: &mut String, der: &[u8]) {
|
||||
bundle.push_str("-----BEGIN CERTIFICATE-----\n");
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(der);
|
||||
for chunk in encoded.as_bytes().chunks(64) {
|
||||
bundle.push_str(&String::from_utf8_lossy(chunk));
|
||||
bundle.push('\n');
|
||||
}
|
||||
bundle.push_str("-----END CERTIFICATE-----\n");
|
||||
}
|
||||
|
||||
fn load_or_create_ca() -> Result<(String, String)> {
|
||||
let (cert_path, key_path) = managed_ca_paths()?;
|
||||
|
||||
@@ -238,6 +334,55 @@ fn write_atomic_create_new(path: &Path, contents: &[u8], mode: u32) -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_atomic_replace(path: &Path, contents: &[u8], mode: u32) -> Result<()> {
|
||||
if fs::read(path).ok().as_deref() == Some(contents) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow!("missing parent directory"))?;
|
||||
fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
if fs::symlink_metadata(path)
|
||||
.ok()
|
||||
.is_some_and(|metadata| metadata.file_type().is_symlink())
|
||||
{
|
||||
return Err(anyhow!("refusing to overwrite symlink {}", path.display()));
|
||||
}
|
||||
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let pid = std::process::id();
|
||||
let file_name = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
let tmp_path = parent.join(format!(".{file_name}.tmp.{pid}.{nanos}"));
|
||||
|
||||
let mut file = open_create_new_with_mode(&tmp_path, mode)?;
|
||||
file.write_all(contents)
|
||||
.with_context(|| format!("failed to write {}", tmp_path.display()))?;
|
||||
file.sync_all()
|
||||
.with_context(|| format!("failed to fsync {}", tmp_path.display()))?;
|
||||
drop(file);
|
||||
|
||||
#[cfg(windows)]
|
||||
if path.exists() {
|
||||
fs::remove_file(path).with_context(|| format!("failed to remove {}", path.display()))?;
|
||||
}
|
||||
fs::rename(&tmp_path, path).with_context(|| {
|
||||
format!(
|
||||
"failed to rename {} -> {}",
|
||||
tmp_path.display(),
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let dir = File::open(parent).with_context(|| format!("failed to open {}", parent.display()))?;
|
||||
dir.sync_all()
|
||||
.with_context(|| format!("failed to fsync {}", parent.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn validate_existing_ca_key_file(path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
@@ -13,6 +13,8 @@ use std::path::Path;
|
||||
use tracing::warn;
|
||||
use url::Url;
|
||||
|
||||
use crate::mitm_hook::MitmHookConfig;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
pub struct NetworkProxyConfig {
|
||||
#[serde(default)]
|
||||
@@ -139,6 +141,8 @@ pub struct NetworkProxySettings {
|
||||
pub allow_local_binding: bool,
|
||||
#[serde(default)]
|
||||
pub mitm: bool,
|
||||
#[serde(default)]
|
||||
pub mitm_hooks: Vec<MitmHookConfig>,
|
||||
}
|
||||
|
||||
impl Default for NetworkProxySettings {
|
||||
@@ -157,6 +161,7 @@ impl Default for NetworkProxySettings {
|
||||
unix_sockets: None,
|
||||
allow_local_binding: false,
|
||||
mitm: false,
|
||||
mitm_hooks: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,8 +278,8 @@ pub enum NetworkMode {
|
||||
/// blocked unless MITM is enabled so the proxy can enforce method policy on inner requests.
|
||||
/// SOCKS5 remains blocked in limited mode.
|
||||
Limited,
|
||||
/// Full network access: all HTTP methods are allowed, and HTTPS CONNECTs are tunneled without
|
||||
/// MITM interception.
|
||||
/// Full network access: all HTTP methods are allowed. HTTPS CONNECTs are tunneled directly.
|
||||
/// MITM hooks do not currently make full mode enter MITM.
|
||||
#[default]
|
||||
Full,
|
||||
}
|
||||
@@ -588,6 +593,7 @@ mod tests {
|
||||
unix_sockets: None,
|
||||
allow_local_binding: false,
|
||||
mitm: false,
|
||||
mitm_hooks: Vec::new(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -652,6 +658,7 @@ mod tests {
|
||||
"unix_sockets": null,
|
||||
"allow_local_binding": false,
|
||||
"mitm": false,
|
||||
"mitm_hooks": [],
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -80,6 +80,9 @@ use tracing::error;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ConnectMitmEnabled(bool);
|
||||
|
||||
pub async fn run_http_proxy(
|
||||
state: Arc<NetworkProxyState>,
|
||||
addr: SocketAddr,
|
||||
@@ -256,10 +259,18 @@ async fn http_connect_accept(
|
||||
return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error"));
|
||||
}
|
||||
};
|
||||
let host_has_mitm_hooks = match app_state.host_has_mitm_hooks(&host).await {
|
||||
Ok(has_hooks) => has_hooks,
|
||||
Err(err) => {
|
||||
error!("failed to inspect MITM hooks for {host}: {err}");
|
||||
return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error"));
|
||||
}
|
||||
};
|
||||
let connect_needs_mitm = mode == NetworkMode::Limited || host_has_mitm_hooks;
|
||||
|
||||
if mode == NetworkMode::Limited && mitm_state.is_none() {
|
||||
// Limited mode is designed to be read-only. Without MITM, a CONNECT tunnel would hide the
|
||||
// inner HTTP method/headers from the proxy, effectively bypassing method policy.
|
||||
if connect_needs_mitm && mitm_state.is_none() {
|
||||
// CONNECT needs MITM whenever HTTPS policy depends on inner-request inspection, either for
|
||||
// limited-mode method enforcement or for host-specific MITM hooks.
|
||||
emit_http_block_decision_audit_event(
|
||||
&app_state,
|
||||
BlockDecisionAuditEventArgs {
|
||||
@@ -286,7 +297,7 @@ async fn http_connect_accept(
|
||||
reason: REASON_MITM_REQUIRED.to_string(),
|
||||
client: client.clone(),
|
||||
method: Some("CONNECT".to_string()),
|
||||
mode: Some(NetworkMode::Limited),
|
||||
mode: Some(mode),
|
||||
protocol: "http-connect".to_string(),
|
||||
decision: Some(details.decision.as_str().to_string()),
|
||||
source: Some(details.source.as_str().to_string()),
|
||||
@@ -295,14 +306,16 @@ async fn http_connect_accept(
|
||||
.await;
|
||||
let client = client.as_deref().unwrap_or_default();
|
||||
warn!(
|
||||
"CONNECT blocked; MITM required for read-only HTTPS in limited mode (client={client}, host={host}, mode=limited, allowed_methods=GET, HEAD, OPTIONS)"
|
||||
"CONNECT blocked; MITM required to enforce HTTPS policy (client={client}, host={host}, mode={mode:?}, hooked_host={host_has_mitm_hooks})"
|
||||
);
|
||||
return Err(blocked_text_with_details(REASON_MITM_REQUIRED, &details));
|
||||
}
|
||||
|
||||
req.extensions_mut().insert(ProxyTarget(authority));
|
||||
req.extensions_mut()
|
||||
.insert(ConnectMitmEnabled(connect_needs_mitm));
|
||||
req.extensions_mut().insert(mode);
|
||||
if let Some(mitm_state) = mitm_state {
|
||||
if connect_needs_mitm && let Some(mitm_state) = mitm_state {
|
||||
req.extensions_mut().insert(mitm_state);
|
||||
}
|
||||
|
||||
@@ -331,7 +344,10 @@ async fn http_connect_proxy(upgraded: Upgraded) -> Result<(), Infallible> {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if mode == NetworkMode::Limited
|
||||
if upgraded
|
||||
.extensions()
|
||||
.get::<ConnectMitmEnabled>()
|
||||
.is_some_and(|enabled| enabled.0)
|
||||
&& upgraded
|
||||
.extensions()
|
||||
.get::<Arc<mitm::MitmState>>()
|
||||
@@ -1094,6 +1110,42 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_connect_accept_blocks_hooked_host_in_full_mode_without_mitm_state() {
|
||||
let mut policy = NetworkProxySettings {
|
||||
mitm: true,
|
||||
mitm_hooks: vec![crate::mitm_hook::MitmHookConfig {
|
||||
host: "api.github.com".to_string(),
|
||||
matcher: crate::mitm_hook::MitmHookMatchConfig {
|
||||
methods: vec!["POST".to_string()],
|
||||
path_prefixes: vec!["/repos/openai/".to_string()],
|
||||
..crate::mitm_hook::MitmHookMatchConfig::default()
|
||||
},
|
||||
actions: crate::mitm_hook::MitmHookActionsConfig::default(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
policy.set_allowed_domains(vec!["api.github.com".to_string()]);
|
||||
let state = Arc::new(network_proxy_state_for_policy(policy));
|
||||
|
||||
let mut req = Request::builder()
|
||||
.method(Method::CONNECT)
|
||||
.uri("https://api.github.com:443")
|
||||
.header("host", "api.github.com:443")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut().insert(state);
|
||||
|
||||
let response = http_connect_accept(/*policy_decider*/ None, req)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
response.headers().get("x-proxy-error").unwrap(),
|
||||
"blocked-by-mitm-required"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_proxy_listener_accepts_plain_http1_connect_requests() {
|
||||
let target_listener = TokioTcpListener::bind((Ipv4Addr::LOCALHOST, 0))
|
||||
|
||||
@@ -5,6 +5,7 @@ mod config;
|
||||
mod connect_policy;
|
||||
mod http_proxy;
|
||||
mod mitm;
|
||||
mod mitm_hook;
|
||||
mod network_policy;
|
||||
mod policy;
|
||||
mod proxy;
|
||||
@@ -23,6 +24,13 @@ pub use config::NetworkProxyConfig;
|
||||
pub use config::NetworkUnixSocketPermission;
|
||||
pub use config::NetworkUnixSocketPermissions;
|
||||
pub use config::host_and_port_from_network_addr;
|
||||
pub use mitm_hook::CredentialedRouteProxyActionConfig;
|
||||
pub use mitm_hook::CredentialedRouteProxyHeader;
|
||||
pub use mitm_hook::InjectedHeaderConfig;
|
||||
pub use mitm_hook::MitmHookActionsConfig;
|
||||
pub use mitm_hook::MitmHookBodyConfig;
|
||||
pub use mitm_hook::MitmHookConfig;
|
||||
pub use mitm_hook::MitmHookMatchConfig;
|
||||
pub use network_policy::NetworkDecision;
|
||||
pub use network_policy::NetworkDecisionSource;
|
||||
pub use network_policy::NetworkPolicyDecider;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use crate::certs::ManagedMitmCa;
|
||||
use crate::config::NetworkMode;
|
||||
use crate::mitm_hook::HookEvaluation;
|
||||
use crate::mitm_hook::MitmHookActions;
|
||||
use crate::policy::normalize_host;
|
||||
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_MITM_HOOK_DENIED;
|
||||
use crate::responses::blocked_text_response;
|
||||
use crate::responses::text_response;
|
||||
use crate::runtime::HostBlockDecision;
|
||||
@@ -23,12 +26,14 @@ use rama_core::rt::Executor;
|
||||
use rama_core::service::service_fn;
|
||||
use rama_http::Body;
|
||||
use rama_http::BodyDataStream;
|
||||
use rama_http::HeaderMap;
|
||||
use rama_http::HeaderValue;
|
||||
use rama_http::Request;
|
||||
use rama_http::Response;
|
||||
use rama_http::StatusCode;
|
||||
use rama_http::Uri;
|
||||
use rama_http::header::HOST;
|
||||
use rama_http::header::HeaderName;
|
||||
use rama_http::layer::remove_header::RemoveRequestHeaderLayer;
|
||||
use rama_http::layer::remove_header::RemoveResponseHeaderLayer;
|
||||
use rama_http_backend::server::HttpServer;
|
||||
@@ -43,6 +48,7 @@ use std::task::Context as TaskContext;
|
||||
use std::task::Poll;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
use url::Url;
|
||||
|
||||
/// State needed to terminate a CONNECT tunnel and enforce policy on inner HTTPS requests.
|
||||
pub struct MitmState {
|
||||
@@ -71,8 +77,19 @@ struct MitmRequestContext {
|
||||
mitm: Arc<MitmState>,
|
||||
}
|
||||
|
||||
enum MitmPolicyDecision {
|
||||
Allow {
|
||||
hook_actions: Option<MitmHookActions>,
|
||||
},
|
||||
Block(Response),
|
||||
}
|
||||
|
||||
const MITM_INSPECT_BODIES: bool = false;
|
||||
const MITM_MAX_BODY_BYTES: usize = 4096;
|
||||
const CREDENTIAL_ROUTE_CONNECTOR_ID_HEADER: &str =
|
||||
"x-openai-internal-credential-route-connector-id";
|
||||
const CREDENTIAL_ROUTE_LINK_ID_HEADER: &str = "x-openai-internal-credential-route-link-id";
|
||||
const CREDENTIAL_ROUTE_REQUEST_URL_HEADER: &str = "x-openai-internal-credential-route-request-url";
|
||||
|
||||
impl std::fmt::Debug for MitmState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
@@ -86,9 +103,10 @@ impl std::fmt::Debug for MitmState {
|
||||
|
||||
impl MitmState {
|
||||
pub(crate) fn new(config: MitmUpstreamConfig) -> Result<Self> {
|
||||
// MITM exists to make limited-mode HTTPS enforceable: once CONNECT is established, plain
|
||||
// proxying would lose visibility into the inner HTTP request. We generate/load a local CA
|
||||
// and issue per-host leaf certs so we can terminate TLS and apply policy.
|
||||
// MITM exists when HTTPS policy depends on the inner request: limited-mode method clamps
|
||||
// and host-specific hooks both need visibility after CONNECT is established. We
|
||||
// generate/load a local CA and issue per-host leaf certs so we can terminate TLS and
|
||||
// apply policy.
|
||||
let ca = ManagedMitmCa::load_or_create()?;
|
||||
|
||||
let upstream = if config.allow_upstream_proxy {
|
||||
@@ -200,9 +218,10 @@ async fn handle_mitm_request(
|
||||
}
|
||||
|
||||
async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Result<Response> {
|
||||
if let Some(response) = mitm_blocking_response(&req, &request_ctx.policy).await? {
|
||||
return Ok(response);
|
||||
}
|
||||
let hook_actions = match evaluate_mitm_policy(&req, &request_ctx.policy).await? {
|
||||
MitmPolicyDecision::Allow { hook_actions } => hook_actions,
|
||||
MitmPolicyDecision::Block(response) => return Ok(response),
|
||||
};
|
||||
|
||||
let target_host = request_ctx.policy.target_host.clone();
|
||||
let target_port = request_ctx.policy.target_port;
|
||||
@@ -213,11 +232,29 @@ async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Resu
|
||||
let log_path = path_for_log(req.uri());
|
||||
|
||||
let (mut parts, body) = req.into_parts();
|
||||
apply_mitm_hook_actions(&mut parts.headers, hook_actions.as_ref());
|
||||
let authority = authority_header_value(&target_host, target_port);
|
||||
parts.uri = build_https_uri(&authority, &path)?;
|
||||
parts
|
||||
.headers
|
||||
.insert(HOST, HeaderValue::from_str(&authority)?);
|
||||
let original_request_uri = build_https_uri(&authority, &path)?;
|
||||
if let Some(actions) = hook_actions.as_ref()
|
||||
&& let Some(credentialed_route_proxy) = actions.credentialed_route_proxy.as_ref()
|
||||
{
|
||||
let (proxy_uri, proxy_authority) =
|
||||
credentialed_route_proxy_request(credentialed_route_proxy)?;
|
||||
apply_credentialed_route_proxy_headers(
|
||||
&mut parts.headers,
|
||||
credentialed_route_proxy,
|
||||
&original_request_uri,
|
||||
)?;
|
||||
parts.uri = proxy_uri;
|
||||
parts
|
||||
.headers
|
||||
.insert(HOST, HeaderValue::from_str(&proxy_authority)?);
|
||||
} else {
|
||||
parts.uri = original_request_uri;
|
||||
parts
|
||||
.headers
|
||||
.insert(HOST, HeaderValue::from_str(&authority)?);
|
||||
}
|
||||
|
||||
let inspect = mitm.inspect_enabled();
|
||||
let max_body_bytes = mitm.max_body_bytes();
|
||||
@@ -247,12 +284,23 @@ async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Resu
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
async fn mitm_blocking_response(
|
||||
req: &Request,
|
||||
policy: &MitmPolicyContext,
|
||||
) -> Result<Option<Response>> {
|
||||
match evaluate_mitm_policy(req, policy).await? {
|
||||
MitmPolicyDecision::Allow { .. } => Ok(None),
|
||||
MitmPolicyDecision::Block(response) => Ok(Some(response)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn evaluate_mitm_policy(
|
||||
req: &Request,
|
||||
policy: &MitmPolicyContext,
|
||||
) -> Result<MitmPolicyDecision> {
|
||||
if req.method().as_str() == "CONNECT" {
|
||||
return Ok(Some(text_response(
|
||||
return Ok(MitmPolicyDecision::Block(text_response(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
"CONNECT not supported inside MITM",
|
||||
)));
|
||||
@@ -272,7 +320,7 @@ async fn mitm_blocking_response(
|
||||
"MITM host mismatch (target={}, request_host={normalized})",
|
||||
policy.target_host
|
||||
);
|
||||
return Ok(Some(text_response(
|
||||
return Ok(MitmPolicyDecision::Block(text_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"host mismatch",
|
||||
)));
|
||||
@@ -307,7 +355,43 @@ async fn mitm_blocking_response(
|
||||
"MITM blocked local/private target after CONNECT (host={}, port={}, method={method}, path={log_path})",
|
||||
policy.target_host, policy.target_port
|
||||
);
|
||||
return Ok(Some(blocked_text_response(reason)));
|
||||
return Ok(MitmPolicyDecision::Block(blocked_text_response(reason)));
|
||||
}
|
||||
|
||||
match policy
|
||||
.app_state
|
||||
.evaluate_mitm_hook_request(&policy.target_host, req)
|
||||
.await?
|
||||
{
|
||||
HookEvaluation::Matched { actions } => {
|
||||
return Ok(MitmPolicyDecision::Allow {
|
||||
hook_actions: Some(actions),
|
||||
});
|
||||
}
|
||||
HookEvaluation::HookedHostNoMatch => {
|
||||
let _ = policy
|
||||
.app_state
|
||||
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
|
||||
host: policy.target_host.clone(),
|
||||
reason: REASON_MITM_HOOK_DENIED.to_string(),
|
||||
client: client.clone(),
|
||||
method: Some(method.clone()),
|
||||
mode: Some(policy.mode),
|
||||
protocol: "https".to_string(),
|
||||
decision: None,
|
||||
source: None,
|
||||
port: Some(policy.target_port),
|
||||
}))
|
||||
.await;
|
||||
warn!(
|
||||
"MITM blocked by hook policy (host={}, method={method}, mode={:?})",
|
||||
policy.target_host, policy.mode
|
||||
);
|
||||
return Ok(MitmPolicyDecision::Block(blocked_text_response(
|
||||
REASON_MITM_HOOK_DENIED,
|
||||
)));
|
||||
}
|
||||
HookEvaluation::NoHooksForHost => {}
|
||||
}
|
||||
|
||||
if !policy.mode.allows_method(&method) {
|
||||
@@ -329,10 +413,25 @@ async fn mitm_blocking_response(
|
||||
"MITM blocked by method policy (host={}, method={method}, path={log_path}, mode={:?}, allowed_methods=GET, HEAD, OPTIONS)",
|
||||
policy.target_host, policy.mode
|
||||
);
|
||||
return Ok(Some(blocked_text_response(REASON_METHOD_NOT_ALLOWED)));
|
||||
return Ok(MitmPolicyDecision::Block(blocked_text_response(
|
||||
REASON_METHOD_NOT_ALLOWED,
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
Ok(MitmPolicyDecision::Allow { hook_actions: None })
|
||||
}
|
||||
|
||||
fn apply_mitm_hook_actions(headers: &mut HeaderMap, actions: Option<&MitmHookActions>) {
|
||||
let Some(actions) = actions else {
|
||||
return;
|
||||
};
|
||||
|
||||
for header_name in &actions.strip_request_headers {
|
||||
headers.remove(header_name);
|
||||
}
|
||||
for injected_header in &actions.inject_request_headers {
|
||||
headers.insert(injected_header.name.clone(), injected_header.value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn respond_with_inspection(
|
||||
@@ -471,6 +570,48 @@ fn build_https_uri(authority: &str, path: &str) -> Result<Uri> {
|
||||
Ok(target.parse()?)
|
||||
}
|
||||
|
||||
fn credentialed_route_proxy_request(
|
||||
action: &crate::mitm_hook::CredentialedRouteProxyAction,
|
||||
) -> Result<(Uri, String)> {
|
||||
let proxy_url = Url::parse(&action.proxy_url)
|
||||
.with_context(|| format!("invalid credentialed route proxy URL {}", action.proxy_url))?;
|
||||
let proxy_authority = url_authority_header_value(&proxy_url)?;
|
||||
Ok((proxy_url.as_str().parse()?, proxy_authority))
|
||||
}
|
||||
|
||||
fn apply_credentialed_route_proxy_headers(
|
||||
headers: &mut HeaderMap,
|
||||
action: &crate::mitm_hook::CredentialedRouteProxyAction,
|
||||
request_uri: &Uri,
|
||||
) -> Result<()> {
|
||||
for proxy_header in &action.proxy_headers {
|
||||
headers.insert(proxy_header.name.clone(), proxy_header.value.clone());
|
||||
}
|
||||
headers.insert(
|
||||
HeaderName::from_static(CREDENTIAL_ROUTE_CONNECTOR_ID_HEADER),
|
||||
HeaderValue::from_str(&action.connector_id)?,
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static(CREDENTIAL_ROUTE_LINK_ID_HEADER),
|
||||
HeaderValue::from_str(&action.link_id)?,
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static(CREDENTIAL_ROUTE_REQUEST_URL_HEADER),
|
||||
HeaderValue::from_str(&request_uri.to_string())?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn url_authority_header_value(url: &Url) -> Result<String> {
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| anyhow!("URL must include a host"))?;
|
||||
let port = url
|
||||
.port_or_known_default()
|
||||
.ok_or_else(|| anyhow!("URL must include a known port"))?;
|
||||
Ok(authority_header_value(host, port))
|
||||
}
|
||||
|
||||
fn path_and_query(uri: &Uri) -> String {
|
||||
uri.path_and_query()
|
||||
.map(rama_http::uri::PathAndQuery::as_str)
|
||||
|
||||
1093
codex-rs/network-proxy/src/mitm_hook.rs
Normal file
1093
codex-rs/network-proxy/src/mitm_hook.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,40 @@ use super::*;
|
||||
|
||||
use crate::config::NetworkProxySettings;
|
||||
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_MITM_HOOK_DENIED;
|
||||
use crate::reasons::REASON_NOT_ALLOWED_LOCAL;
|
||||
use crate::runtime::network_proxy_state_for_policy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rama_http::Body;
|
||||
use rama_http::HeaderMap;
|
||||
use rama_http::HeaderValue;
|
||||
use rama_http::Method;
|
||||
use rama_http::Request;
|
||||
use rama_http::StatusCode;
|
||||
use rama_http::header::HeaderName;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
fn github_write_hook() -> crate::mitm_hook::MitmHookConfig {
|
||||
crate::mitm_hook::MitmHookConfig {
|
||||
host: "api.github.com".to_string(),
|
||||
matcher: crate::mitm_hook::MitmHookMatchConfig {
|
||||
methods: vec!["POST".to_string(), "PUT".to_string()],
|
||||
path_prefixes: vec!["/repos/openai/".to_string()],
|
||||
..crate::mitm_hook::MitmHookMatchConfig::default()
|
||||
},
|
||||
actions: crate::mitm_hook::MitmHookActionsConfig {
|
||||
strip_request_headers: vec!["authorization".to_string()],
|
||||
inject_request_headers: vec![crate::mitm_hook::InjectedHeaderConfig {
|
||||
name: "authorization".to_string(),
|
||||
secret_env_var: Some("CODEX_GITHUB_TOKEN".to_string()),
|
||||
secret_file: None,
|
||||
prefix: Some("Bearer ".to_string()),
|
||||
}],
|
||||
credentialed_route_proxy: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn policy_ctx(
|
||||
app_state: Arc<NetworkProxyState>,
|
||||
@@ -126,3 +153,176 @@ async fn mitm_policy_rechecks_local_private_target_after_connect() {
|
||||
assert_eq!(blocked[0].host, "10.0.0.1");
|
||||
assert_eq!(blocked[0].port, Some(443));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mitm_policy_allows_matching_hooked_write_in_full_mode() {
|
||||
let secret_file = NamedTempFile::new().unwrap();
|
||||
std::fs::write(secret_file.path(), "ghp-secret\n").unwrap();
|
||||
let mut hook = github_write_hook();
|
||||
hook.actions.inject_request_headers[0].secret_env_var = None;
|
||||
hook.actions.inject_request_headers[0].secret_file =
|
||||
Some(secret_file.path().display().to_string());
|
||||
let mut network = NetworkProxySettings {
|
||||
mitm: true,
|
||||
mitm_hooks: vec![hook],
|
||||
mode: NetworkMode::Full,
|
||||
..NetworkProxySettings::default()
|
||||
};
|
||||
network.set_allowed_domains(vec!["api.github.com".to_string()]);
|
||||
let app_state = Arc::new(network_proxy_state_for_policy(network));
|
||||
let ctx = policy_ctx(
|
||||
app_state.clone(),
|
||||
NetworkMode::Full,
|
||||
"api.github.com",
|
||||
/*target_port*/ 443,
|
||||
);
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/repos/openai/codex/issues")
|
||||
.header(HOST, "api.github.com")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let response = mitm_blocking_response(&req, &ctx).await.unwrap();
|
||||
|
||||
assert!(
|
||||
response.is_none(),
|
||||
"matching hook should bypass method clamp"
|
||||
);
|
||||
assert_eq!(app_state.blocked_snapshot().await.unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mitm_policy_blocks_hook_miss_for_hooked_host_and_records_telemetry_in_full_mode() {
|
||||
let secret_file = NamedTempFile::new().unwrap();
|
||||
std::fs::write(secret_file.path(), "ghp-secret\n").unwrap();
|
||||
let mut hook = github_write_hook();
|
||||
hook.actions.inject_request_headers[0].secret_env_var = None;
|
||||
hook.actions.inject_request_headers[0].secret_file =
|
||||
Some(secret_file.path().display().to_string());
|
||||
let mut network = NetworkProxySettings {
|
||||
mitm: true,
|
||||
mitm_hooks: vec![hook],
|
||||
mode: NetworkMode::Full,
|
||||
..NetworkProxySettings::default()
|
||||
};
|
||||
network.set_allowed_domains(vec!["api.github.com".to_string()]);
|
||||
let app_state = Arc::new(network_proxy_state_for_policy(network));
|
||||
let ctx = policy_ctx(
|
||||
app_state.clone(),
|
||||
NetworkMode::Full,
|
||||
"api.github.com",
|
||||
/*target_port*/ 443,
|
||||
);
|
||||
let req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/repos/openai/codex/issues?token=secret")
|
||||
.header(HOST, "api.github.com")
|
||||
.header("authorization", "Bearer user-supplied")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let response = mitm_blocking_response(&req, &ctx)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("hook miss should be blocked");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
response.headers().get("x-proxy-error").unwrap(),
|
||||
"blocked-by-mitm-hook"
|
||||
);
|
||||
|
||||
let blocked = app_state.drain_blocked().await.unwrap();
|
||||
assert_eq!(blocked.len(), 1);
|
||||
assert_eq!(blocked[0].reason, REASON_MITM_HOOK_DENIED);
|
||||
assert_eq!(blocked[0].method.as_deref(), Some("GET"));
|
||||
assert_eq!(blocked[0].host, "api.github.com");
|
||||
assert_eq!(blocked[0].port, Some(443));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_mitm_hook_actions_replaces_authorization_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_static("Bearer user-supplied"),
|
||||
);
|
||||
headers.append(
|
||||
HeaderName::from_static("x-request-id"),
|
||||
HeaderValue::from_static("req_123"),
|
||||
);
|
||||
|
||||
let actions = crate::mitm_hook::MitmHookActions {
|
||||
strip_request_headers: vec![HeaderName::from_static("authorization")],
|
||||
inject_request_headers: vec![crate::mitm_hook::ResolvedInjectedHeader {
|
||||
name: HeaderName::from_static("authorization"),
|
||||
value: HeaderValue::from_static("Bearer secret-token"),
|
||||
source: crate::mitm_hook::SecretSource::File(
|
||||
AbsolutePathBuf::try_from("/tmp/github-token").unwrap(),
|
||||
),
|
||||
}],
|
||||
credentialed_route_proxy: None,
|
||||
};
|
||||
|
||||
apply_mitm_hook_actions(&mut headers, Some(&actions));
|
||||
|
||||
assert_eq!(
|
||||
headers.get("authorization"),
|
||||
Some(&HeaderValue::from_static("Bearer secret-token"))
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("x-request-id"),
|
||||
Some(&HeaderValue::from_static("req_123"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentialed_route_proxy_request_rewrites_upstream_target() {
|
||||
let action = crate::mitm_hook::CredentialedRouteProxyAction {
|
||||
connector_id: "connector_123".to_string(),
|
||||
link_id: "link_123".to_string(),
|
||||
proxy_headers: vec![crate::mitm_hook::CredentialedRouteProxyHeader {
|
||||
name: HeaderName::from_static("authorization"),
|
||||
value: HeaderValue::from_static("Bearer codex-token"),
|
||||
}],
|
||||
proxy_url: "http://localhost:8080/api/codex/credential_routes/proxy".to_string(),
|
||||
};
|
||||
let request_uri: Uri = "https://api.example.com/v1/items?limit=5".parse().unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_static("Bearer provider-token"),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static(CREDENTIAL_ROUTE_REQUEST_URL_HEADER),
|
||||
HeaderValue::from_static("https://spoofed.example.com"),
|
||||
);
|
||||
|
||||
let (proxy_uri, proxy_authority) = credentialed_route_proxy_request(&action).unwrap();
|
||||
apply_credentialed_route_proxy_headers(&mut headers, &action, &request_uri).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
proxy_uri.to_string(),
|
||||
"http://localhost:8080/api/codex/credential_routes/proxy"
|
||||
);
|
||||
assert_eq!(proxy_authority, "localhost:8080");
|
||||
assert_eq!(
|
||||
headers.get("authorization"),
|
||||
Some(&HeaderValue::from_static("Bearer codex-token"))
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get(CREDENTIAL_ROUTE_CONNECTOR_ID_HEADER),
|
||||
Some(&HeaderValue::from_static("connector_123"))
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get(CREDENTIAL_ROUTE_LINK_ID_HEADER),
|
||||
Some(&HeaderValue::from_static("link_123"))
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get(CREDENTIAL_ROUTE_REQUEST_URL_HEADER),
|
||||
Some(&HeaderValue::from_static(
|
||||
"https://api.example.com/v1/items?limit=5"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ use clap::Parser;
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::TcpListener as StdTcpListener;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::RwLock;
|
||||
@@ -223,7 +225,7 @@ impl NetworkProxyBuilder {
|
||||
socks_enabled: current_cfg.network.enable_socks5,
|
||||
runtime_settings: Arc::new(RwLock::new(NetworkProxyRuntimeSettings::from_config(
|
||||
¤t_cfg,
|
||||
))),
|
||||
)?)),
|
||||
reserved_listeners,
|
||||
policy_decider: self.policy_decider,
|
||||
})
|
||||
@@ -299,15 +301,22 @@ struct NetworkProxyRuntimeSettings {
|
||||
allow_local_binding: bool,
|
||||
allow_unix_sockets: Arc<[String]>,
|
||||
dangerously_allow_all_unix_sockets: bool,
|
||||
mitm_ca_trust_bundle_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl NetworkProxyRuntimeSettings {
|
||||
fn from_config(config: &config::NetworkProxyConfig) -> Self {
|
||||
Self {
|
||||
fn from_config(config: &config::NetworkProxyConfig) -> Result<Self> {
|
||||
let mitm_ca_trust_bundle_path = config
|
||||
.network
|
||||
.mitm
|
||||
.then(|| crate::certs::managed_ca_trust_bundle_path(&std::env::vars().collect()))
|
||||
.transpose()?;
|
||||
Ok(Self {
|
||||
allow_local_binding: config.network.allow_local_binding,
|
||||
allow_unix_sockets: config.network.allow_unix_sockets().into(),
|
||||
dangerously_allow_all_unix_sockets: config.network.dangerously_allow_all_unix_sockets,
|
||||
}
|
||||
mitm_ca_trust_bundle_path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,6 +412,16 @@ pub const PROXY_ENV_KEYS: &[&str] = &[
|
||||
"all_proxy",
|
||||
"FTP_PROXY",
|
||||
"ftp_proxy",
|
||||
"CODEX_CA_CERTIFICATE",
|
||||
"SSL_CERT_FILE",
|
||||
"REQUESTS_CA_BUNDLE",
|
||||
"CURL_CA_BUNDLE",
|
||||
"NODE_EXTRA_CA_CERTS",
|
||||
"GIT_SSL_CAINFO",
|
||||
"PIP_CERT",
|
||||
"BUNDLE_SSL_CA_CERT",
|
||||
"npm_config_cafile",
|
||||
"NPM_CONFIG_CAFILE",
|
||||
];
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -475,6 +494,7 @@ fn apply_proxy_env_overrides(
|
||||
socks_addr: SocketAddr,
|
||||
socks_enabled: bool,
|
||||
allow_local_binding: bool,
|
||||
mitm_ca_trust_bundle_path: Option<&Path>,
|
||||
) {
|
||||
let http_proxy_url = format!("http://{http_addr}");
|
||||
let socks_proxy_url = format!("socks5h://{socks_addr}");
|
||||
@@ -552,6 +572,26 @@ fn apply_proxy_env_overrides(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(mitm_ca_trust_bundle_path) = mitm_ca_trust_bundle_path {
|
||||
let mitm_ca_trust_bundle_path = mitm_ca_trust_bundle_path.to_string_lossy();
|
||||
set_env_keys(
|
||||
env,
|
||||
&[
|
||||
"CODEX_CA_CERTIFICATE",
|
||||
"SSL_CERT_FILE",
|
||||
"REQUESTS_CA_BUNDLE",
|
||||
"CURL_CA_BUNDLE",
|
||||
"NODE_EXTRA_CA_CERTS",
|
||||
"GIT_SSL_CAINFO",
|
||||
"PIP_CERT",
|
||||
"BUNDLE_SSL_CA_CERT",
|
||||
"npm_config_cafile",
|
||||
"NPM_CONFIG_CAFILE",
|
||||
],
|
||||
&mitm_ca_trust_bundle_path,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkProxy {
|
||||
@@ -592,7 +632,7 @@ impl NetworkProxy {
|
||||
}
|
||||
|
||||
pub fn apply_to_env(&self, env: &mut HashMap<String, String>) {
|
||||
let allow_local_binding = self.allow_local_binding();
|
||||
let runtime_settings = self.runtime_settings();
|
||||
// Enforce proxying for child processes. We intentionally override existing values so
|
||||
// command-level environment cannot bypass the managed proxy endpoint.
|
||||
apply_proxy_env_overrides(
|
||||
@@ -600,7 +640,8 @@ impl NetworkProxy {
|
||||
self.http_addr,
|
||||
self.socks_addr,
|
||||
self.socks_enabled,
|
||||
allow_local_binding,
|
||||
runtime_settings.allow_local_binding,
|
||||
runtime_settings.mitm_ca_trust_bundle_path.as_deref(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -627,7 +668,7 @@ impl NetworkProxy {
|
||||
"cannot update network.enable_socks5_udp on a running proxy"
|
||||
);
|
||||
|
||||
let settings = NetworkProxyRuntimeSettings::from_config(&new_state.config);
|
||||
let settings = NetworkProxyRuntimeSettings::from_config(&new_state.config)?;
|
||||
self.state.replace_config_state(new_state).await?;
|
||||
let mut guard = self
|
||||
.runtime_settings
|
||||
@@ -975,6 +1016,7 @@ mod tests {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
|
||||
/*socks_enabled*/ true,
|
||||
/*allow_local_binding*/ false,
|
||||
/*mitm_ca_trust_bundle_path*/ None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -1037,6 +1079,7 @@ mod tests {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
|
||||
/*socks_enabled*/ true,
|
||||
/*allow_local_binding*/ false,
|
||||
/*mitm_ca_trust_bundle_path*/ None,
|
||||
);
|
||||
|
||||
for key in env.keys() {
|
||||
@@ -1049,6 +1092,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_proxy_env_overrides_sets_mitm_ca_trust_bundle_vars() {
|
||||
let mut env = HashMap::new();
|
||||
let mitm_ca_trust_bundle_path = Path::new("/tmp/codex-proxy/ca-bundle.pem");
|
||||
apply_proxy_env_overrides(
|
||||
&mut env,
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
|
||||
/*socks_enabled*/ true,
|
||||
/*allow_local_binding*/ false,
|
||||
Some(mitm_ca_trust_bundle_path),
|
||||
);
|
||||
|
||||
for key in [
|
||||
"CODEX_CA_CERTIFICATE",
|
||||
"SSL_CERT_FILE",
|
||||
"REQUESTS_CA_BUNDLE",
|
||||
"CURL_CA_BUNDLE",
|
||||
"NODE_EXTRA_CA_CERTS",
|
||||
"GIT_SSL_CAINFO",
|
||||
"PIP_CERT",
|
||||
"BUNDLE_SSL_CA_CERT",
|
||||
"npm_config_cafile",
|
||||
"NPM_CONFIG_CAFILE",
|
||||
] {
|
||||
assert_eq!(
|
||||
env.get(key),
|
||||
Some(&mitm_ca_trust_bundle_path.display().to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_proxy_env_overrides_uses_http_for_all_proxy_without_socks() {
|
||||
let mut env = HashMap::new();
|
||||
@@ -1058,6 +1133,7 @@ mod tests {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
|
||||
/*socks_enabled*/ false,
|
||||
/*allow_local_binding*/ true,
|
||||
/*mitm_ca_trust_bundle_path*/ None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -1076,6 +1152,7 @@ mod tests {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
|
||||
/*socks_enabled*/ true,
|
||||
/*allow_local_binding*/ false,
|
||||
/*mitm_ca_trust_bundle_path*/ None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -1124,6 +1201,7 @@ mod tests {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
|
||||
/*socks_enabled*/ true,
|
||||
/*allow_local_binding*/ false,
|
||||
/*mitm_ca_trust_bundle_path*/ None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -1146,6 +1224,7 @@ mod tests {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 48081),
|
||||
/*socks_enabled*/ true,
|
||||
/*allow_local_binding*/ false,
|
||||
/*mitm_ca_trust_bundle_path*/ None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -1169,6 +1248,7 @@ mod tests {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 48081),
|
||||
/*socks_enabled*/ true,
|
||||
/*allow_local_binding*/ false,
|
||||
/*mitm_ca_trust_bundle_path*/ None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub(crate) const REASON_DENIED: &str = "denied";
|
||||
pub(crate) const REASON_METHOD_NOT_ALLOWED: &str = "method_not_allowed";
|
||||
pub(crate) const REASON_MITM_HOOK_DENIED: &str = "mitm_hook_denied";
|
||||
pub(crate) const REASON_MITM_REQUIRED: &str = "mitm_required";
|
||||
pub(crate) const REASON_NOT_ALLOWED: &str = "not_allowed";
|
||||
pub(crate) const REASON_NOT_ALLOWED_LOCAL: &str = "not_allowed_local";
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::network_policy::NetworkPolicyDecision;
|
||||
use crate::network_policy::NetworkProtocol;
|
||||
use crate::reasons::REASON_DENIED;
|
||||
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_MITM_HOOK_DENIED;
|
||||
use crate::reasons::REASON_MITM_REQUIRED;
|
||||
use crate::reasons::REASON_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_NOT_ALLOWED_LOCAL;
|
||||
@@ -53,6 +54,7 @@ pub fn blocked_header_value(reason: &str) -> &'static str {
|
||||
REASON_NOT_ALLOWED | REASON_NOT_ALLOWED_LOCAL => "blocked-by-allowlist",
|
||||
REASON_DENIED => "blocked-by-denylist",
|
||||
REASON_METHOD_NOT_ALLOWED => "blocked-by-method-policy",
|
||||
REASON_MITM_HOOK_DENIED => "blocked-by-mitm-hook",
|
||||
REASON_MITM_REQUIRED => "blocked-by-mitm-required",
|
||||
_ => "blocked-by-policy",
|
||||
}
|
||||
@@ -64,6 +66,7 @@ pub fn blocked_message(reason: &str) -> &'static str {
|
||||
REASON_NOT_ALLOWED_LOCAL => "Sandbox policy blocks local/private network addresses.",
|
||||
REASON_DENIED => "Domain denied by the sandbox policy.",
|
||||
REASON_METHOD_NOT_ALLOWED => "Method not allowed in limited mode.",
|
||||
REASON_MITM_HOOK_DENIED => "HTTPS request denied by MITM hook policy.",
|
||||
REASON_MITM_REQUIRED => "MITM required for limited HTTPS.",
|
||||
REASON_PROXY_DISABLED => "network proxy is disabled",
|
||||
_ => "Request blocked by network policy.",
|
||||
|
||||
@@ -3,6 +3,9 @@ use crate::config::NetworkMode;
|
||||
use crate::config::NetworkProxyConfig;
|
||||
use crate::config::ValidatedUnixSocketPath;
|
||||
use crate::mitm::MitmState;
|
||||
use crate::mitm_hook::HookEvaluation;
|
||||
use crate::mitm_hook::MitmHooksByHost;
|
||||
use crate::mitm_hook::evaluate_mitm_hooks;
|
||||
use crate::policy::Host;
|
||||
use crate::policy::is_loopback_host;
|
||||
use crate::policy::is_non_public_ip;
|
||||
@@ -159,6 +162,7 @@ pub struct ConfigState {
|
||||
pub allow_set: GlobSet,
|
||||
pub deny_set: GlobSet,
|
||||
pub mitm: Option<Arc<MitmState>>,
|
||||
pub mitm_hooks: MitmHooksByHost,
|
||||
pub constraints: NetworkProxyConstraints,
|
||||
pub blocked: VecDeque<BlockedRequest>,
|
||||
pub blocked_total: u64,
|
||||
@@ -585,6 +589,22 @@ impl NetworkProxyState {
|
||||
Ok(guard.mitm.clone())
|
||||
}
|
||||
|
||||
pub(crate) async fn evaluate_mitm_hook_request(
|
||||
&self,
|
||||
host: &str,
|
||||
req: &rama_http::Request,
|
||||
) -> Result<HookEvaluation> {
|
||||
self.reload_if_needed().await?;
|
||||
let guard = self.state.read().await;
|
||||
Ok(evaluate_mitm_hooks(&guard.mitm_hooks, host, req))
|
||||
}
|
||||
|
||||
pub async fn host_has_mitm_hooks(&self, host: &str) -> Result<bool> {
|
||||
self.reload_if_needed().await?;
|
||||
let guard = self.state.read().await;
|
||||
Ok(guard.mitm_hooks.contains_key(&normalize_host(host)))
|
||||
}
|
||||
|
||||
pub async fn add_allowed_domain(&self, host: &str) -> Result<()> {
|
||||
self.update_domain_list(host, DomainListKind::Allow).await
|
||||
}
|
||||
@@ -846,9 +866,23 @@ pub(crate) fn network_proxy_state_for_policy(
|
||||
mut network: crate::config::NetworkProxySettings,
|
||||
) -> NetworkProxyState {
|
||||
network.enabled = true;
|
||||
network.mode = NetworkMode::Full;
|
||||
let config = NetworkProxyConfig { network };
|
||||
let state = build_config_state(config, NetworkProxyConstraints::default()).unwrap();
|
||||
let state = ConfigState {
|
||||
allow_set: crate::policy::compile_allowlist_globset(
|
||||
&config.network.allowed_domains().unwrap_or_default(),
|
||||
)
|
||||
.unwrap(),
|
||||
blocked: VecDeque::new(),
|
||||
blocked_total: 0,
|
||||
config: config.clone(),
|
||||
constraints: NetworkProxyConstraints::default(),
|
||||
deny_set: crate::policy::compile_denylist_globset(
|
||||
&config.network.denied_domains().unwrap_or_default(),
|
||||
)
|
||||
.unwrap(),
|
||||
mitm: None,
|
||||
mitm_hooks: crate::mitm_hook::compile_mitm_hooks(&config).unwrap(),
|
||||
};
|
||||
|
||||
NetworkProxyState::with_reloader(state, Arc::new(NoopReloader))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ use crate::config::NetworkProxyConfig;
|
||||
use crate::config::NetworkUnixSocketPermissions;
|
||||
use crate::mitm::MitmState;
|
||||
use crate::mitm::MitmUpstreamConfig;
|
||||
use crate::mitm_hook::MitmHookConfig;
|
||||
use crate::mitm_hook::compile_mitm_hooks;
|
||||
use crate::mitm_hook::validate_mitm_hook_config;
|
||||
use crate::policy::DomainPattern;
|
||||
use crate::policy::compile_allowlist_globset;
|
||||
use crate::policy::compile_denylist_globset;
|
||||
@@ -53,6 +56,9 @@ pub struct PartialNetworkConfig {
|
||||
#[serde(default)]
|
||||
pub unix_sockets: Option<NetworkUnixSocketPermissions>,
|
||||
pub allow_local_binding: Option<bool>,
|
||||
pub mitm: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub mitm_hooks: Option<Vec<MitmHookConfig>>,
|
||||
}
|
||||
|
||||
pub fn build_config_state(
|
||||
@@ -66,6 +72,7 @@ pub fn build_config_state(
|
||||
.map_err(NetworkProxyConstraintError::into_anyhow)?;
|
||||
let deny_set = compile_denylist_globset(&denied_domains)?;
|
||||
let allow_set = compile_allowlist_globset(&allowed_domains)?;
|
||||
let mitm_hooks = compile_mitm_hooks(&config)?;
|
||||
let mitm = if config.network.mitm {
|
||||
Some(Arc::new(MitmState::new(MitmUpstreamConfig {
|
||||
allow_upstream_proxy: config.network.allow_upstream_proxy,
|
||||
@@ -79,6 +86,7 @@ pub fn build_config_state(
|
||||
allow_set,
|
||||
deny_set,
|
||||
mitm,
|
||||
mitm_hooks,
|
||||
constraints,
|
||||
blocked: std::collections::VecDeque::new(),
|
||||
blocked_total: 0,
|
||||
@@ -116,6 +124,7 @@ pub fn validate_policy_against_constraints(
|
||||
.map(|entry| entry.to_ascii_lowercase())
|
||||
.collect();
|
||||
let config_allow_unix_sockets = config.network.allow_unix_sockets();
|
||||
validate_mitm_hook_config(config).map_err(invalid_mitm_hook_configuration)?;
|
||||
validate_non_global_wildcard_domain_patterns("network.denied_domains", &config_denied_domains)?;
|
||||
if let Some(max_enabled) = constraints.enabled {
|
||||
validate(enabled, move |candidate| {
|
||||
@@ -376,6 +385,14 @@ pub fn validate_policy_against_constraints(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn invalid_mitm_hook_configuration(err: anyhow::Error) -> NetworkProxyConstraintError {
|
||||
NetworkProxyConstraintError::InvalidValue {
|
||||
field_name: "network.mitm_hooks",
|
||||
candidate: err.to_string(),
|
||||
allowed: "valid MITM hook configuration".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_non_global_wildcard_domain_patterns(
|
||||
field_name: &'static str,
|
||||
patterns: &[String],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::connect_policy::TargetCheckedTcpConnector;
|
||||
use crate::state::NetworkProxyState;
|
||||
use codex_utils_rustls_provider::ensure_rustls_crypto_provider;
|
||||
use rama_core::Layer;
|
||||
use rama_core::Service;
|
||||
use rama_core::error::BoxError;
|
||||
@@ -225,6 +226,7 @@ fn build_http_connector(
|
||||
EstablishedClientConnection<HttpClientService<Body>, Request<Body>>,
|
||||
BoxError,
|
||||
> {
|
||||
ensure_rustls_crypto_provider();
|
||||
let proxy = HttpProxyConnectorLayer::optional().into_layer(transport);
|
||||
let tls_config = TlsConnectorDataBuilder::new()
|
||||
.with_alpn_protocols_http_auto()
|
||||
|
||||
Reference in New Issue
Block a user