Merge bdafb7ab46 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-05-20 14:28:36 -07:00
committed by GitHub
14 changed files with 762 additions and 50 deletions
@@ -8,6 +8,9 @@ Usage: build-codex-package-archive.sh \
--bundle <primary|app-server> \
--entrypoint-dir <dir> \
--archive-dir <dir> \
[--bwrap-bin <path>] \
[--codex-command-runner-bin <path>] \
[--codex-windows-sandbox-setup-bin <path>] \
[--target-suffixed-entrypoint]
EOF
}
@@ -17,6 +20,10 @@ bundle=""
entrypoint_dir=""
archive_dir=""
target_suffixed_entrypoint="false"
resource_args=()
bwrap_bin_provided="false"
command_runner_bin_provided="false"
sandbox_setup_bin_provided="false"
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -36,6 +43,27 @@ while [[ $# -gt 0 ]]; do
archive_dir="${2:?--archive-dir requires a value}"
shift 2
;;
--bwrap-bin)
resource_args+=(--bwrap-bin "${2:?--bwrap-bin requires a value}")
bwrap_bin_provided="true"
shift 2
;;
--codex-command-runner-bin)
resource_args+=(
--codex-command-runner-bin
"${2:?--codex-command-runner-bin requires a value}"
)
command_runner_bin_provided="true"
shift 2
;;
--codex-windows-sandbox-setup-bin)
resource_args+=(
--codex-windows-sandbox-setup-bin
"${2:?--codex-windows-sandbox-setup-bin requires a value}"
)
sandbox_setup_bin_provided="true"
shift 2
;;
--target-suffixed-entrypoint)
target_suffixed_entrypoint="true"
shift
@@ -86,6 +114,25 @@ if [[ "$target_suffixed_entrypoint" == "true" ]]; then
entrypoint_name="${entrypoint_name}-${target}"
fi
case "$target" in
*linux*)
bwrap_bin="${entrypoint_dir%/}/bwrap"
if [[ "$bwrap_bin_provided" == "false" && -f "$bwrap_bin" ]]; then
resource_args+=(--bwrap-bin "$bwrap_bin")
fi
;;
*windows*)
command_runner_bin="${entrypoint_dir%/}/codex-command-runner.exe"
sandbox_setup_bin="${entrypoint_dir%/}/codex-windows-sandbox-setup.exe"
if [[ "$command_runner_bin_provided" == "false" && -f "$command_runner_bin" ]]; then
resource_args+=(--codex-command-runner-bin "$command_runner_bin")
fi
if [[ "$sandbox_setup_bin_provided" == "false" && -f "$sandbox_setup_bin" ]]; then
resource_args+=(--codex-windows-sandbox-setup-bin "$sandbox_setup_bin")
fi
;;
esac
repo_root="${GITHUB_WORKSPACE:-}"
if [[ -z "$repo_root" ]]; then
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
@@ -115,4 +162,5 @@ rm -rf "$package_dir"
--package-dir "$package_dir" \
--archive-output "$gzip_archive_path" \
--archive-output "$zstd_archive_path" \
"${resource_args[@]}" \
--force
+1 -1
View File
@@ -346,7 +346,7 @@ jobs:
with:
target: ${{ matrix.target }}
- if: ${{ contains(matrix.target, 'linux') && matrix.bundle == 'primary' }}
- if: ${{ contains(matrix.target, 'linux') }}
name: Build bwrap and export digest
shell: bash
run: |
+59 -7
View File
@@ -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))
+83 -11
View File
@@ -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,6 +26,7 @@ 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;
@@ -71,6 +75,13 @@ 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;
@@ -86,9 +97,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 +212,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,6 +226,7 @@ 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
@@ -247,12 +261,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 +297,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,9 +332,41 @@ 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)));
}
let hook_actions = match policy
.app_state
.evaluate_mitm_hook_request(&policy.target_host, req)
.await?
{
HookEvaluation::Matched { 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 => None,
};
if !policy.mode.allows_method(&method) {
let _ = policy
.app_state
@@ -329,10 +386,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 })
}
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(
+192
View File
@@ -2,13 +2,39 @@ 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()),
}],
},
}
}
fn policy_ctx(
app_state: Arc<NetworkProxyState>,
@@ -126,3 +152,169 @@ 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 be allowed in full mode"
);
assert_eq!(app_state.blocked_snapshot().await.unwrap().len(), 0);
}
#[tokio::test]
async fn mitm_policy_blocks_matching_hooked_write_in_limited_mode() {
let mut hook = github_write_hook();
hook.actions.inject_request_headers.clear();
let mut network = NetworkProxySettings {
mitm: true,
mitm_hooks: vec![hook],
mode: NetworkMode::Limited,
..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::Limited,
"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()
.expect("matching POST hook should still be blocked in limited mode");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response.headers().get("x-proxy-error").unwrap(),
"blocked-by-method-policy"
);
let blocked = app_state.drain_blocked().await.unwrap();
assert_eq!(blocked.len(), 1);
assert_eq!(blocked[0].reason, REASON_METHOD_NOT_ALLOWED);
assert_eq!(blocked[0].method.as_deref(), Some("POST"));
assert_eq!(blocked[0].host, "api.github.com");
assert_eq!(blocked[0].port, Some(443));
}
#[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(),
),
}],
};
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"))
);
}
+1
View File
@@ -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
View File
@@ -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.",
+36 -2
View File
@@ -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))
}
+107
View File
@@ -12,6 +12,7 @@ use crate::network_policy::emit_block_decision_audit_event;
use crate::network_policy::evaluate_host_policy;
use crate::policy::normalize_host;
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
use crate::reasons::REASON_MITM_REQUIRED;
use crate::reasons::REASON_PROXY_DISABLED;
use crate::responses::PolicyDecisionDetails;
use crate::responses::blocked_message_with_policy;
@@ -240,6 +241,51 @@ async fn handle_socks5_tcp(
}
}
match app_state.host_has_mitm_hooks(&host).await {
Ok(true) => {
emit_socks_block_decision_audit_event(
&app_state,
NetworkDecisionSource::ModeGuard,
REASON_MITM_REQUIRED,
NetworkProtocol::Socks5Tcp,
host.as_str(),
port,
client.as_deref(),
);
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_MITM_REQUIRED,
source: NetworkDecisionSource::ModeGuard,
protocol: NetworkProtocol::Socks5Tcp,
host: &host,
port,
};
let _ = app_state
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
host: host.clone(),
reason: REASON_MITM_REQUIRED.to_string(),
client: client.clone(),
method: None,
mode: Some(NetworkMode::Full),
protocol: "socks5".to_string(),
decision: Some(details.decision.as_str().to_string()),
source: Some(details.source.as_str().to_string()),
port: Some(port),
}))
.await;
let client = client.as_deref().unwrap_or_default();
warn!(
"SOCKS blocked; MITM required to enforce HTTPS policy (client={client}, host={host}, mode=full)"
);
return Err(policy_denied_error(REASON_MITM_REQUIRED, &details).into());
}
Ok(false) => {}
Err(err) => {
error!("failed to inspect MITM hooks for {host}: {err}");
return Err(io::Error::other("proxy error").into());
}
}
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
protocol: NetworkProtocol::Socks5Tcp,
host: host.clone(),
@@ -501,11 +547,14 @@ mod tests {
use crate::config::NetworkMode;
use crate::config::NetworkProxyConfig;
use crate::config::NetworkProxySettings;
use crate::mitm_hook::MitmHookConfig;
use crate::mitm_hook::MitmHookMatchConfig;
use crate::network_policy::test_support::POLICY_DECISION_EVENT_NAME;
use crate::network_policy::test_support::capture_events;
use crate::network_policy::test_support::find_event_by_name;
use crate::runtime::ConfigReloader;
use crate::runtime::ConfigState;
use crate::runtime::network_proxy_state_for_policy;
use crate::state::NetworkProxyConstraints;
use crate::state::build_config_state;
use async_trait::async_trait;
@@ -589,6 +638,64 @@ mod tests {
assert_eq!(event.field("client.address"), Some("unknown"));
}
#[tokio::test(flavor = "current_thread")]
async fn handle_socks5_tcp_blocks_hooked_host_in_full_mode() {
let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings {
enabled: true,
mode: NetworkMode::Full,
mitm: true,
mitm_hooks: vec![MitmHookConfig {
host: "api.github.com".to_string(),
matcher: MitmHookMatchConfig {
methods: vec!["GET".to_string()],
path_prefixes: vec!["/".to_string()],
..MitmHookMatchConfig::default()
},
..MitmHookConfig::default()
}],
..NetworkProxySettings::default()
}));
let mut request =
TcpRequest::new(HostWithPort::try_from("api.github.com:443").expect("valid authority"));
request.extensions_mut().insert(state.clone());
let (result, events) = capture_events(|| async {
handle_socks5_tcp(
request,
TargetCheckedTcpConnector::new(state.clone()),
/*policy_decider*/ None,
)
.await
})
.await;
assert!(result.is_err(), "hooked host should require MITM");
let blocked = state.drain_blocked().await.unwrap();
assert_eq!(blocked.len(), 1);
assert_eq!(blocked[0].reason, REASON_MITM_REQUIRED);
assert_eq!(blocked[0].host, "api.github.com");
assert_eq!(blocked[0].port, Some(443));
assert_eq!(blocked[0].protocol, "socks5");
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
.expect("expected policy decision event");
assert_eq!(event.field("network.policy.scope"), Some("non_domain"));
assert_eq!(event.field("network.policy.decision"), Some("deny"));
assert_eq!(event.field("network.policy.source"), Some("mode_guard"));
assert_eq!(
event.field("network.policy.reason"),
Some(REASON_MITM_REQUIRED)
);
assert_eq!(
event.field("network.transport.protocol"),
Some("socks5_tcp")
);
assert_eq!(event.field("server.address"), Some("api.github.com"));
assert_eq!(event.field("server.port"), Some("443"));
assert_eq!(event.field("http.request.method"), Some("none"));
assert_eq!(event.field("client.address"), Some("unknown"));
}
#[tokio::test(flavor = "current_thread")]
async fn inspect_socks5_udp_emits_block_decision_for_mode_guard_deny() {
let state = state_for_settings(NetworkProxySettings {
+3
View File
@@ -5,6 +5,7 @@ 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;
@@ -71,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,
@@ -84,6 +86,7 @@ pub fn build_config_state(
allow_set,
deny_set,
mitm,
mitm_hooks,
constraints,
blocked: std::collections::VecDeque::new(),
blocked_total: 0,
+12 -4
View File
@@ -35,12 +35,14 @@ read from `[workspace.package].version` in `codex-rs/Cargo.toml`.
## Source-built artifacts
Artifacts built from this repository are always built by the package builder in
one grouped `cargo build` command per package when they are needed:
Artifacts built from this repository are built by the package builder in one
grouped `cargo build` command per package when they are needed and no prebuilt
override was provided:
- all targets: the selected entrypoint, unless `--entrypoint-bin` is provided
- Linux targets: `bwrap`
- Windows targets: `codex-command-runner` and `codex-windows-sandbox-setup`
- Linux targets: `bwrap`, unless `--bwrap-bin` is provided
- Windows targets: `codex-command-runner` and `codex-windows-sandbox-setup`,
unless the corresponding prebuilt helper flags are provided
The default cargo profile is `dev-small` because local iteration should favor
fast, small builds. Release jobs should pass `--cargo-profile release` and an
@@ -48,6 +50,12 @@ explicit target. Release jobs that already built and signed/notarized the
entrypoint should pass `--entrypoint-bin` so the package contains that exact
binary instead of rebuilding it.
Release jobs that already built package resource binaries should also pass the
corresponding resource flags: `--bwrap-bin` for Linux packages, and
`--codex-command-runner-bin` plus `--codex-windows-sandbox-setup-bin` for
Windows packages. This keeps package archive creation as a pure staging step
after signing instead of rebuilding resources.
`rg` is not built from this repository, so the builder fetches it from the
DotSlash manifest at `codex-cli/bin/rg`. Downloaded archives are cached under
`$TMPDIR/codex-package/<target>-rg` and are reused only after the recorded size
+61 -17
View File
@@ -28,11 +28,25 @@ def build_source_binaries(
cargo: str,
profile: str,
entrypoint_bin: Path | None,
bwrap_bin: Path | None,
codex_command_runner_bin: Path | None,
codex_windows_sandbox_setup_bin: Path | None,
) -> SourceBuildOutputs:
validate_prebuilt_resource_inputs(
spec,
bwrap_bin=bwrap_bin,
codex_command_runner_bin=codex_command_runner_bin,
codex_windows_sandbox_setup_bin=codex_windows_sandbox_setup_bin,
)
binaries = source_binaries_for_target(
spec,
variant,
build_entrypoint=entrypoint_bin is None,
build_bwrap=spec.is_linux and bwrap_bin is None,
build_codex_command_runner=spec.is_windows
and codex_command_runner_bin is None,
build_codex_windows_sandbox_setup=spec.is_windows
and codex_windows_sandbox_setup_bin is None,
)
if binaries:
cmd = [
@@ -51,17 +65,21 @@ def build_source_binaries(
output_dir = cargo_profile_output_dir(spec, profile)
outputs = SourceBuildOutputs(
entrypoint_bin=(
entrypoint_bin.resolve()
if entrypoint_bin is not None
else output_dir / variant.entrypoint_name(spec)
entrypoint_bin=resolve_output_path(
entrypoint_bin,
output_dir / variant.entrypoint_name(spec),
),
bwrap_bin=output_dir / "bwrap" if spec.is_linux else None,
codex_command_runner_bin=(
output_dir / "codex-command-runner.exe" if spec.is_windows else None
bwrap_bin=resolve_output_path(
bwrap_bin,
output_dir / "bwrap" if spec.is_linux else None,
),
codex_windows_sandbox_setup_bin=(
output_dir / "codex-windows-sandbox-setup.exe" if spec.is_windows else None
codex_command_runner_bin=resolve_output_path(
codex_command_runner_bin,
output_dir / "codex-command-runner.exe" if spec.is_windows else None,
),
codex_windows_sandbox_setup_bin=resolve_output_path(
codex_windows_sandbox_setup_bin,
output_dir / "codex-windows-sandbox-setup.exe" if spec.is_windows else None,
),
)
validate_source_outputs(outputs)
@@ -73,22 +91,48 @@ def source_binaries_for_target(
variant: PackageVariant,
*,
build_entrypoint: bool,
build_bwrap: bool,
build_codex_command_runner: bool,
build_codex_windows_sandbox_setup: bool,
) -> list[str]:
binaries = []
if build_entrypoint:
binaries.append(variant.cargo_bin)
if spec.is_linux:
if build_bwrap:
binaries.append("bwrap")
if spec.is_windows:
binaries.extend(
[
"codex-command-runner",
"codex-windows-sandbox-setup",
]
)
if build_codex_command_runner:
binaries.append("codex-command-runner")
if build_codex_windows_sandbox_setup:
binaries.append("codex-windows-sandbox-setup")
return binaries
def validate_prebuilt_resource_inputs(
spec: TargetSpec,
*,
bwrap_bin: Path | None,
codex_command_runner_bin: Path | None,
codex_windows_sandbox_setup_bin: Path | None,
) -> None:
if bwrap_bin is not None and not spec.is_linux:
raise RuntimeError("--bwrap-bin is only supported for Linux targets.")
if codex_command_runner_bin is not None and not spec.is_windows:
raise RuntimeError(
"--codex-command-runner-bin is only supported for Windows targets."
)
if codex_windows_sandbox_setup_bin is not None and not spec.is_windows:
raise RuntimeError(
"--codex-windows-sandbox-setup-bin is only supported for Windows targets."
)
def resolve_output_path(explicit_path: Path | None, default_path: Path | None) -> Path | None:
if explicit_path is not None:
return explicit_path.resolve()
return default_path
def cargo_profile_output_dir(spec: TargetSpec, profile: str) -> Path:
target_dir = cargo_target_dir()
return target_dir / spec.target / cargo_profile_dirname(profile)
+56 -8
View File
@@ -84,6 +84,32 @@ def parse_args() -> argparse.Namespace:
"variant. If omitted, the entrypoint is built with Cargo."
),
)
parser.add_argument(
"--bwrap-bin",
type=Path,
help=(
"Optional prebuilt Linux bwrap executable. If omitted for Linux "
"targets, bwrap is built with Cargo."
),
)
parser.add_argument(
"--codex-command-runner-bin",
type=Path,
help=(
"Optional prebuilt Windows codex-command-runner.exe executable. "
"If omitted for Windows targets, codex-command-runner is built "
"with Cargo."
),
)
parser.add_argument(
"--codex-windows-sandbox-setup-bin",
type=Path,
help=(
"Optional prebuilt Windows codex-windows-sandbox-setup.exe "
"executable. If omitted for Windows targets, "
"codex-windows-sandbox-setup is built with Cargo."
),
)
parser.add_argument(
"--rg-bin",
type=Path,
@@ -111,14 +137,25 @@ def main() -> int:
variant,
cargo=args.cargo,
profile=args.cargo_profile,
entrypoint_bin=(
resolve_input_path(
args.entrypoint_bin,
"prebuilt entrypoint executable",
"--entrypoint-bin",
)
if args.entrypoint_bin is not None
else None
entrypoint_bin=resolve_optional_input_path(
args.entrypoint_bin,
"prebuilt entrypoint executable",
"--entrypoint-bin",
),
bwrap_bin=resolve_optional_input_path(
args.bwrap_bin,
"prebuilt Linux bwrap executable",
"--bwrap-bin",
),
codex_command_runner_bin=resolve_optional_input_path(
args.codex_command_runner_bin,
"prebuilt Windows codex-command-runner.exe executable",
"--codex-command-runner-bin",
),
codex_windows_sandbox_setup_bin=resolve_optional_input_path(
args.codex_windows_sandbox_setup_bin,
"prebuilt Windows codex-windows-sandbox-setup.exe executable",
"--codex-windows-sandbox-setup-bin",
),
)
version = read_workspace_version()
@@ -141,3 +178,14 @@ def main() -> int:
print(f"Built Codex package directory at {package_dir}")
return 0
def resolve_optional_input_path(
explicit_path: Path | None,
description: str,
flag_name: str,
) -> Path | None:
if explicit_path is None:
return None
return resolve_input_path(explicit_path, description, flag_name)
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
from __future__ import annotations
from pathlib import Path
import sys
import tempfile
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from codex_package.cargo import build_source_binaries
from codex_package.cargo import source_binaries_for_target
from codex_package.targets import PACKAGE_VARIANTS
from codex_package.targets import TARGET_SPECS
class SourceBinariesForTargetTest(unittest.TestCase):
def test_macos_package_with_prebuilt_entrypoint_builds_nothing(self) -> None:
self.assertEqual(
source_binaries_for_target(
TARGET_SPECS["aarch64-apple-darwin"],
PACKAGE_VARIANTS["codex"],
build_entrypoint=False,
build_bwrap=False,
build_codex_command_runner=False,
build_codex_windows_sandbox_setup=False,
),
[],
)
def test_linux_package_with_prebuilt_entrypoint_and_bwrap_builds_nothing(self) -> None:
self.assertEqual(
source_binaries_for_target(
TARGET_SPECS["x86_64-unknown-linux-musl"],
PACKAGE_VARIANTS["codex"],
build_entrypoint=False,
build_bwrap=False,
build_codex_command_runner=False,
build_codex_windows_sandbox_setup=False,
),
[],
)
def test_windows_package_with_prebuilt_entrypoint_and_helpers_builds_nothing(self) -> None:
self.assertEqual(
source_binaries_for_target(
TARGET_SPECS["x86_64-pc-windows-msvc"],
PACKAGE_VARIANTS["codex"],
build_entrypoint=False,
build_bwrap=False,
build_codex_command_runner=False,
build_codex_windows_sandbox_setup=False,
),
[],
)
def test_missing_windows_helpers_are_built(self) -> None:
self.assertEqual(
source_binaries_for_target(
TARGET_SPECS["x86_64-pc-windows-msvc"],
PACKAGE_VARIANTS["codex"],
build_entrypoint=False,
build_bwrap=False,
build_codex_command_runner=True,
build_codex_windows_sandbox_setup=True,
),
["codex-command-runner", "codex-windows-sandbox-setup"],
)
def test_build_uses_prebuilt_windows_helpers_without_running_cargo(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
entrypoint = touch_file(root / "codex.exe")
command_runner = touch_file(root / "codex-command-runner.exe")
sandbox_setup = touch_file(root / "codex-windows-sandbox-setup.exe")
outputs = build_source_binaries(
TARGET_SPECS["x86_64-pc-windows-msvc"],
PACKAGE_VARIANTS["codex"],
cargo=str(root / "cargo-that-should-not-run"),
profile="release",
entrypoint_bin=entrypoint,
bwrap_bin=None,
codex_command_runner_bin=command_runner,
codex_windows_sandbox_setup_bin=sandbox_setup,
)
self.assertEqual(outputs.entrypoint_bin, entrypoint)
self.assertEqual(outputs.codex_command_runner_bin, command_runner)
self.assertEqual(outputs.codex_windows_sandbox_setup_bin, sandbox_setup)
def touch_file(path: Path) -> Path:
path.write_text("", encoding="utf-8")
return path.resolve()
if __name__ == "__main__":
unittest.main()