mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
Support configured credential providers in remote execution (#56049)
## Why Remote credential masking previously fell back to unmasked execution whenever custom credential providers were configured. ## What changed - Transport configured provider rules to the executor-local network proxy, where credential values and destination context are resolved. - Advertise `credentialMaskingCustomProviders` and negotiate masking support. Keep the warning and unmasked fallback for executors that support only built-in providers. - Discard provider rules when masking is disabled and omit their contents from debug output. ## Testing Extend coverage for capability compatibility, provider configuration round trips, and fallback on older executors. Exercise configured-provider masking in remote request history and verify that masked placeholders are restored to real credentials in upstream request headers. GitOrigin-RevId: b036f56900b343ddccc17b5293c82bc1f3426237
This commit is contained in:
@@ -10,6 +10,7 @@ use codex_network_proxy::LocalBindingPolicy::RequireTrue;
|
||||
use codex_network_proxy::NetworkDomainPermission;
|
||||
use codex_network_proxy::NetworkUnixSocketPermission;
|
||||
use codex_network_proxy::NetworkUnixSocketPermissions;
|
||||
use codex_network_proxy::RemoteCredentialMasking;
|
||||
use codex_protocol::models::ManagedFileSystemPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
@@ -105,7 +106,7 @@ async fn attachment_socket_grants_respect_configured_restrictions_at_remote_laun
|
||||
/*fallback_policy_decider*/ None,
|
||||
)?;
|
||||
let launch = scoped
|
||||
.remote_launch_config(DefaultFalse, /*credential_masking*/ false)
|
||||
.remote_launch_config(DefaultFalse, RemoteCredentialMasking::Disabled)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
launch.proxy.dangerously_allow_all_unix_sockets, expected,
|
||||
@@ -122,7 +123,7 @@ async fn attachment_socket_grants_respect_configured_restrictions_at_remote_laun
|
||||
// Inheritance must not grant sockets to ordinary controller-only commands.
|
||||
assert!(
|
||||
!proxy
|
||||
.remote_launch_config(DefaultFalse, /*credential_masking*/ false)
|
||||
.remote_launch_config(DefaultFalse, RemoteCredentialMasking::Disabled)
|
||||
.await?
|
||||
.proxy
|
||||
.dangerously_allow_all_unix_sockets
|
||||
@@ -130,7 +131,7 @@ async fn attachment_socket_grants_respect_configured_restrictions_at_remote_laun
|
||||
state.add_allowed_domain("granted.example").await?;
|
||||
assert!(
|
||||
scoped
|
||||
.remote_launch_config(DefaultFalse, /*credential_masking*/ false)
|
||||
.remote_launch_config(DefaultFalse, RemoteCredentialMasking::Disabled)
|
||||
.await?
|
||||
.proxy
|
||||
.dangerously_allow_all_unix_sockets
|
||||
@@ -151,7 +152,7 @@ async fn attachment_socket_grants_respect_configured_restrictions_at_remote_laun
|
||||
.await?;
|
||||
assert!(
|
||||
!scoped
|
||||
.remote_launch_config(DefaultFalse, /*credential_masking*/ false)
|
||||
.remote_launch_config(DefaultFalse, RemoteCredentialMasking::Disabled)
|
||||
.await?
|
||||
.proxy
|
||||
.dangerously_allow_all_unix_sockets
|
||||
@@ -893,7 +894,7 @@ async fn environment_local_binding_preserves_explicit_denials_and_inherits_omitt
|
||||
Some(Arc::new(|_request| async { NetworkDecision::Allow })),
|
||||
)?;
|
||||
let launch = scoped
|
||||
.remote_launch_config(local_binding_policy, /*credential_masking*/ false)
|
||||
.remote_launch_config(local_binding_policy, RemoteCredentialMasking::Disabled)
|
||||
.await;
|
||||
assert_eq!(proxy.current_cfg().await?, carrier.config, "{name}");
|
||||
if local_binding_policy == RequireTrue && !expected {
|
||||
|
||||
@@ -40,11 +40,11 @@ use crate::unified_exec::TerminalSandboxSource;
|
||||
use crate::unified_exec::UnifiedExecProcess;
|
||||
use crate::unified_exec::UnifiedExecProcessManager;
|
||||
use codex_core_plugins::PluginMetricsSidecar;
|
||||
use codex_exec_server::EnvironmentCapabilities;
|
||||
use codex_features::Feature;
|
||||
use codex_network_proxy::CREDENTIAL_BROKER_ACTIVE_ENV_KEY;
|
||||
use codex_network_proxy::ManagedNetworkSandboxContext;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_network_proxy::RemoteCredentialMasking;
|
||||
use codex_network_proxy::RemoteNetworkProxyConfig;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::models::AdditionalPermissionProfile;
|
||||
@@ -352,13 +352,18 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
|
||||
"failed to query exec-server capabilities: {err}"
|
||||
))))
|
||||
})?;
|
||||
let credential_masking = ctx
|
||||
let network_proxy = environment_info.capabilities.network_proxy();
|
||||
let credential_masking = if ctx
|
||||
.step_context
|
||||
.turn
|
||||
.config
|
||||
.features
|
||||
.enabled(Feature::CredentialMasking)
|
||||
&& environment_info.capabilities.credential_masking;
|
||||
{
|
||||
network_proxy.credential_masking
|
||||
} else {
|
||||
RemoteCredentialMasking::Disabled
|
||||
};
|
||||
let mut launch = network
|
||||
.remote_launch_config(
|
||||
crate::windows_sandbox::local_binding_policy_for_sandbox(
|
||||
@@ -374,7 +379,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
|
||||
warn_if_remote_credential_masking_unavailable(
|
||||
ctx,
|
||||
req,
|
||||
&environment_info.capabilities,
|
||||
network_proxy.credential_masking,
|
||||
&launch.proxy,
|
||||
)
|
||||
.await;
|
||||
@@ -402,7 +407,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
|
||||
if !launch.proxy.enabled {
|
||||
(env, None, None)
|
||||
} else {
|
||||
if !environment_info.capabilities.network_proxy_launch {
|
||||
if !network_proxy.launch {
|
||||
return Err(ToolError::Rejected(
|
||||
"selected exec-server does not support executor-local network proxy launches"
|
||||
.to_string(),
|
||||
@@ -701,7 +706,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
|
||||
async fn warn_if_remote_credential_masking_unavailable(
|
||||
ctx: &ToolCtx,
|
||||
req: &UnifiedExecRequest,
|
||||
capabilities: &EnvironmentCapabilities,
|
||||
credential_masking: RemoteCredentialMasking,
|
||||
proxy_config: &RemoteNetworkProxyConfig,
|
||||
) {
|
||||
if !ctx
|
||||
@@ -715,11 +720,11 @@ async fn warn_if_remote_credential_masking_unavailable(
|
||||
}
|
||||
|
||||
let environment_id = &req.turn_environment.selection.environment_id;
|
||||
let message = if !capabilities.credential_masking {
|
||||
let message = if credential_masking == RemoteCredentialMasking::Disabled {
|
||||
format!(
|
||||
"Credential masking is unavailable on environment `{environment_id}` because its exec-server does not support it. This command will run with unmasked credentials."
|
||||
)
|
||||
} else if !proxy_config.credential_masking {
|
||||
} else if !proxy_config.credential_masking_enabled() {
|
||||
format!(
|
||||
"Credential masking is unavailable for this network proxy configuration on environment `{environment_id}`. This command will run with unmasked credentials."
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ use pretty_assertions::assert_eq;
|
||||
use pretty_assertions::assert_ne;
|
||||
|
||||
#[test_case::test_case(false, false, None; "builtin_providers")]
|
||||
#[test_case::test_case(true, false, None; "configured_provider_fallback")]
|
||||
#[test_case::test_case(true, false, None; "configured_provider")]
|
||||
#[test_case::test_case(true, true, None; "configured_provider_login_fallback")]
|
||||
#[test_case::test_case(true, false, Some("BASH_ENV"); "configured_provider_executor_startup_fallback")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -27,7 +27,8 @@ async fn remote_credential_masking_request_history(
|
||||
eprintln!("skipping credential masking: this scenario requires a Bash or Zsh executor");
|
||||
return Ok(());
|
||||
}
|
||||
let use_zsh_snapshot = shell.name() == "zsh" && !configured_provider;
|
||||
let expect_masking = !use_login_shell && executor_startup_env.is_none();
|
||||
let use_zsh_snapshot = shell.name() == "zsh" && expect_masking;
|
||||
let home = Arc::new(TempDir::new()?);
|
||||
if let Some(warning) =
|
||||
codex_sandboxing::system_bwrap_warning(&PermissionProfile::workspace_write(), home.path())
|
||||
@@ -35,11 +36,19 @@ async fn remote_credential_masking_request_history(
|
||||
eprintln!("skipping sandbox test: {warning}");
|
||||
return Ok(());
|
||||
}
|
||||
const API_KEY: &str = "sk-proj-executor-only-0000000000000000000000000000000000";
|
||||
let (credential_env, credential, prefix) = if configured_provider {
|
||||
("VENDOR_TOKEN", "vendor_abcdefgh", "vendor_")
|
||||
} else {
|
||||
(
|
||||
"OPENAI_API_KEY",
|
||||
"sk-proj-executor-only-0000000000000000000000000000000000",
|
||||
"sk-proj-",
|
||||
)
|
||||
};
|
||||
const CALL_ID: &str = "print-credential";
|
||||
// Use our own native executor so only its environment contains the credential.
|
||||
let mut executor_env = vec![
|
||||
("OPENAI_API_KEY", API_KEY),
|
||||
(credential_env, credential),
|
||||
("OPENAI_BASE_URL", "https://api.openai.com/v1"),
|
||||
];
|
||||
executor_env.extend(executor_startup_env.map(|key| (key, "/dev/null")));
|
||||
@@ -78,7 +87,7 @@ allow_local_binding = true
|
||||
|
||||
[shell_environment_policy]
|
||||
ignore_default_excludes = true
|
||||
include_only = ["HOME", "PATH", "OPENAI_API_KEY", "OPENAI_BASE_URL"{startup_env}]
|
||||
include_only = ["HOME", "PATH", "{credential_env}", "OPENAI_BASE_URL"{startup_env}]
|
||||
|
||||
[features]
|
||||
unified_exec = true
|
||||
@@ -104,13 +113,14 @@ credential_masking = true
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
let mut command = if configured_provider {
|
||||
let mut command = if !expect_masking {
|
||||
// Keep the real credential out of the model-visible command text.
|
||||
fs::write(test.workspace_path("expected-api-key"), API_KEY)?;
|
||||
"test \"$OPENAI_API_KEY\" = \"$(<expected-api-key)\" && printf 'unmasked fallback'"
|
||||
.to_string()
|
||||
fs::write(test.workspace_path("expected-api-key"), credential)?;
|
||||
format!(
|
||||
"test \"${credential_env}\" = \"$(<expected-api-key)\" && printf 'unmasked fallback'"
|
||||
)
|
||||
} else {
|
||||
"printf '%s' \"$OPENAI_API_KEY\"".to_string()
|
||||
format!("printf '%s' \"${credential_env}\"")
|
||||
};
|
||||
if let Some(key) = executor_startup_env {
|
||||
command = format!("test -n \"${key}\" && {command}");
|
||||
@@ -143,11 +153,11 @@ credential_masking = true
|
||||
.await;
|
||||
|
||||
test.codex
|
||||
.start_or_steer_turn(TurnInputRequest::user_input(vec![text(
|
||||
"Print OPENAI_API_KEY from the executor environment.",
|
||||
)]))
|
||||
.start_or_steer_turn(TurnInputRequest::user_input(vec![text(&format!(
|
||||
"Print {credential_env} from the executor environment.",
|
||||
))]))
|
||||
.await?;
|
||||
if configured_provider {
|
||||
if use_login_shell || shell.name() == "zsh" && !use_zsh_snapshot {
|
||||
let warning = wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::Warning(warning) if warning.message.starts_with("Credential masking is unavailable"))
|
||||
})
|
||||
@@ -155,7 +165,7 @@ credential_masking = true
|
||||
let EventMsg::Warning(warning) = warning else {
|
||||
unreachable!("waited for a credential masking warning")
|
||||
};
|
||||
insta::assert_snapshot!(warning.message, @"Credential masking is unavailable for this network proxy configuration on environment `remote`. This command will run with unmasked credentials.");
|
||||
insta::assert_snapshot!(warning.message, @"Credential masking is unavailable for this shell startup on environment `remote`. This command will run with unmasked credentials.");
|
||||
}
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
@@ -165,29 +175,31 @@ credential_masking = true
|
||||
assert_eq!(requests.len(), 2);
|
||||
// Check complete, raw requests before any snapshot rewriting or normalization.
|
||||
for request in &requests {
|
||||
assert!(!request.body_json().to_string().contains(API_KEY));
|
||||
assert!(!request.body_json().to_string().contains(credential));
|
||||
}
|
||||
let output = requests[1]
|
||||
.function_call_output_text(CALL_ID)
|
||||
.context("model follow-up should include the executor output")?;
|
||||
assert!(output.contains("Process exited with code 0"), "{output}");
|
||||
if configured_provider {
|
||||
if !expect_masking {
|
||||
assert!(output.ends_with("Output:\nunmasked fallback"), "{output}");
|
||||
return Ok(());
|
||||
}
|
||||
let (_, placeholder) = output
|
||||
.split_once("Output:\n")
|
||||
.context("executor output should contain a credential placeholder")?;
|
||||
assert!(placeholder.starts_with("sk-proj-"));
|
||||
assert_eq!(placeholder.len(), API_KEY.len());
|
||||
assert_ne!(placeholder, API_KEY);
|
||||
assert!(placeholder.starts_with(prefix));
|
||||
assert_eq!(placeholder.len(), credential.len());
|
||||
assert_ne!(placeholder, credential);
|
||||
|
||||
// Share the full-history snapshots after normalizing the provider-specific variable name.
|
||||
let mut snapshot = context_snapshot::format_request_history_snapshot(
|
||||
"A remote executor masks its credential before the command output reaches the model.",
|
||||
&requests,
|
||||
&ContextSnapshotOptions::default().rewrite_known_segments(),
|
||||
)
|
||||
.replace(placeholder, "<CREDENTIAL_PLACEHOLDER>");
|
||||
.replace(placeholder, "<CREDENTIAL_PLACEHOLDER>")
|
||||
.replace(credential_env, "<CREDENTIAL_ENV>");
|
||||
for (pattern, replacement) in [
|
||||
(r"Chunk ID: [a-z0-9]+", "Chunk ID: <CHUNK_ID>"),
|
||||
(
|
||||
|
||||
+2
-2
@@ -13,9 +13,9 @@ Scenario: A remote executor masks its credential before the command output reach
|
||||
02:message/user:
|
||||
<ENVIRONMENT_CONTEXT>
|
||||
03:message/user:
|
||||
Print OPENAI_API_KEY from the executor environment.
|
||||
Print <CREDENTIAL_ENV> from the executor environment.
|
||||
-- request 2 (turn) --
|
||||
04:function_call/exec_command:{"cmd":"printf '%s' \\"$OPENAI_API_KEY\\"","environment_id":"remote","login":false,"yield_time_ms":1000}
|
||||
04:function_call/exec_command:{"cmd":"printf '%s' \\"$<CREDENTIAL_ENV>\\"","environment_id":"remote","login":false,"yield_time_ms":1000}
|
||||
05:function_call_output:Chunk ID: <CHUNK_ID>
|
||||
Wall time: <DURATION> seconds
|
||||
Process exited with code 0
|
||||
|
||||
+2
-2
@@ -13,9 +13,9 @@ Scenario: A remote executor masks its credential before the command output reach
|
||||
02:message/user:
|
||||
<ENVIRONMENT_CONTEXT>
|
||||
03:message/user:
|
||||
Print OPENAI_API_KEY from the executor environment.
|
||||
Print <CREDENTIAL_ENV> from the executor environment.
|
||||
-- request 2 (turn) --
|
||||
04:function_call/exec_command:{"cmd":"printf '%s' \\"$OPENAI_API_KEY\\"","environment_id":"remote","login":true,"yield_time_ms":1000}
|
||||
04:function_call/exec_command:{"cmd":"printf '%s' \\"$<CREDENTIAL_ENV>\\"","environment_id":"remote","login":true,"yield_time_ms":1000}
|
||||
05:function_call_output:Chunk ID: <CHUNK_ID>
|
||||
Wall time: <DURATION> seconds
|
||||
Process exited with code 0
|
||||
|
||||
@@ -76,6 +76,7 @@ const REPLAY_RETAINED_OUTPUT_SEQ: u64 = 800;
|
||||
enum PushedExecScenario {
|
||||
Complete,
|
||||
CredentialMasking,
|
||||
CredentialMaskingCustomProvidersUnsupported,
|
||||
CredentialMaskingUnsupportedShell,
|
||||
CredentialMaskingUnsupported,
|
||||
OutputAfterExit,
|
||||
@@ -201,7 +202,9 @@ async fn respond_environment_info(
|
||||
json!({ "name": "sh", "path": "/bin/sh" })
|
||||
} else if matches!(
|
||||
scenario,
|
||||
PushedExecScenario::CredentialMasking | PushedExecScenario::CredentialMaskingUnsupported
|
||||
PushedExecScenario::CredentialMasking
|
||||
| PushedExecScenario::CredentialMaskingCustomProvidersUnsupported
|
||||
| PushedExecScenario::CredentialMaskingUnsupported
|
||||
) {
|
||||
json!({ "name": "bash", "path": "/bin/bash" })
|
||||
} else {
|
||||
@@ -215,6 +218,7 @@ async fn respond_environment_info(
|
||||
| PushedExecScenario::DenyOldLinuxWithRoot(_)
|
||||
| PushedExecScenario::DenyLinux
|
||||
| PushedExecScenario::CredentialMasking
|
||||
| PushedExecScenario::CredentialMaskingCustomProvidersUnsupported
|
||||
| PushedExecScenario::CredentialMaskingUnsupportedShell
|
||||
| PushedExecScenario::CredentialMaskingUnsupported => Some("linux"),
|
||||
_ => None,
|
||||
@@ -223,6 +227,7 @@ async fn respond_environment_info(
|
||||
if matches!(
|
||||
scenario,
|
||||
PushedExecScenario::CredentialMasking
|
||||
| PushedExecScenario::CredentialMaskingCustomProvidersUnsupported
|
||||
| PushedExecScenario::CredentialMaskingUnsupportedShell
|
||||
) {
|
||||
capabilities["credentialMasking"] = json!(true);
|
||||
@@ -566,6 +571,7 @@ async fn serve_exec_with_pushed_events(
|
||||
}
|
||||
PushedExecScenario::Complete
|
||||
| PushedExecScenario::CredentialMasking
|
||||
| PushedExecScenario::CredentialMaskingCustomProvidersUnsupported
|
||||
| PushedExecScenario::CredentialMaskingUnsupportedShell
|
||||
| PushedExecScenario::CredentialMaskingUnsupported
|
||||
| PushedExecScenario::DenyWin
|
||||
@@ -722,6 +728,7 @@ async fn serve_exec_with_pushed_events(
|
||||
}
|
||||
PushedExecScenario::Complete
|
||||
| PushedExecScenario::CredentialMasking
|
||||
| PushedExecScenario::CredentialMaskingCustomProvidersUnsupported
|
||||
| PushedExecScenario::CredentialMaskingUnsupportedShell
|
||||
| PushedExecScenario::CredentialMaskingUnsupported
|
||||
| PushedExecScenario::CodexPath
|
||||
@@ -859,6 +866,7 @@ async fn serve_exec_with_pushed_events(
|
||||
#[test_case(PushedExecScenario::ReplayGap, ManagedNetworkScenario::None, false ; "truncated_event_replay")]
|
||||
#[test_case(PushedExecScenario::Complete, ManagedNetworkScenario::Enabled { policy_callbacks: true }, false ; "managed_network_uses_executor_proxy_launch")]
|
||||
#[test_case(PushedExecScenario::CredentialMasking, ManagedNetworkScenario::Enabled { policy_callbacks: true }, false ; "credential_masking_launches_on_supported_executor")]
|
||||
#[test_case(PushedExecScenario::CredentialMaskingCustomProvidersUnsupported, ManagedNetworkScenario::Enabled { policy_callbacks: true }, false ; "configured_credential_provider_warns_and_runs_on_old_executor")]
|
||||
#[test_case(PushedExecScenario::CredentialMaskingUnsupported, ManagedNetworkScenario::Enabled { policy_callbacks: true }, false ; "credential_masking_warns_and_runs_unsupported_executor")]
|
||||
#[test_case(PushedExecScenario::CredentialMaskingUnsupportedShell, ManagedNetworkScenario::Enabled { policy_callbacks: true }, false ; "credential_masking_warns_and_runs_unsupported_shell")]
|
||||
#[test_case(PushedExecScenario::Complete, ManagedNetworkScenario::Enabled { policy_callbacks: false }, false ; "strict_managed_allowlist_omits_policy_callbacks")]
|
||||
@@ -904,6 +912,7 @@ async fn exec_command_consumes_pushed_remote_process_events(
|
||||
let credential_masking = matches!(
|
||||
scenario,
|
||||
PushedExecScenario::CredentialMasking
|
||||
| PushedExecScenario::CredentialMaskingCustomProvidersUnsupported
|
||||
| PushedExecScenario::CredentialMaskingUnsupported
|
||||
| PushedExecScenario::CredentialMaskingUnsupportedShell
|
||||
);
|
||||
@@ -1085,6 +1094,19 @@ enabled = false
|
||||
builder = builder
|
||||
.with_cloud_config_bundle(cloud_config_bundle)
|
||||
.with_pre_build_hook(move |home| {
|
||||
let provider_config = if matches!(
|
||||
scenario,
|
||||
PushedExecScenario::CredentialMaskingCustomProvidersUnsupported
|
||||
) {
|
||||
r#"[features.network_proxy.credentials.vendor]
|
||||
env = ["VENDOR_TOKEN"]
|
||||
patterns = ["vendor_[a-z]{8}"]
|
||||
url_prefix_from_env = "VENDOR_BASE_URL"
|
||||
auth = ["bearer"]
|
||||
"#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
fs::write(
|
||||
home.join("config.toml"),
|
||||
format!(
|
||||
@@ -1101,6 +1123,7 @@ allow_local_binding = true
|
||||
[features]
|
||||
hooks = true
|
||||
credential_masking = {credential_masking}
|
||||
{provider_config}
|
||||
|
||||
[hooks]
|
||||
|
||||
@@ -1757,6 +1780,10 @@ timeout = 900
|
||||
}
|
||||
);
|
||||
assert_eq!(params["networkProxy"]["proxy"]["mode"], "full");
|
||||
assert_eq!(
|
||||
params["networkProxy"]["proxy"]["credentialProviders"],
|
||||
Value::Null
|
||||
);
|
||||
assert_eq!(
|
||||
params["networkProxy"]["policyDecisionTimeoutMs"].as_u64(),
|
||||
policy_callbacks.then_some(1_000_000)
|
||||
@@ -1774,6 +1801,7 @@ timeout = 900
|
||||
scenario,
|
||||
PushedExecScenario::CredentialMaskingUnsupportedShell
|
||||
| PushedExecScenario::CredentialMaskingUnsupported
|
||||
| PushedExecScenario::CredentialMaskingCustomProvidersUnsupported
|
||||
) {
|
||||
let shell = if matches!(
|
||||
scenario,
|
||||
@@ -1796,6 +1824,11 @@ timeout = 900
|
||||
PushedExecScenario::CredentialMaskingUnsupportedShell
|
||||
) {
|
||||
insta::assert_snapshot!(warning.message, @"Credential masking is unavailable for shell `sh` on environment `remote`. This command will run with unmasked credentials.");
|
||||
} else if matches!(
|
||||
scenario,
|
||||
PushedExecScenario::CredentialMaskingCustomProvidersUnsupported
|
||||
) {
|
||||
insta::assert_snapshot!(warning.message, @"Credential masking is unavailable for this network proxy configuration on environment `remote`. This command will run with unmasked credentials.");
|
||||
} else {
|
||||
insta::assert_snapshot!(warning.message, @"Credential masking is unavailable on environment `remote` because its exec-server does not support it. This command will run with unmasked credentials.");
|
||||
}
|
||||
@@ -1884,6 +1917,7 @@ timeout = 900
|
||||
}
|
||||
PushedExecScenario::Complete
|
||||
| PushedExecScenario::CredentialMasking
|
||||
| PushedExecScenario::CredentialMaskingCustomProvidersUnsupported
|
||||
| PushedExecScenario::CredentialMaskingUnsupportedShell
|
||||
| PushedExecScenario::CredentialMaskingUnsupported
|
||||
| PushedExecScenario::DenyWin
|
||||
|
||||
@@ -8,6 +8,7 @@ pub use codex_file_system::WalkOutcome;
|
||||
use codex_file_system::WireFileSystemSandboxContext;
|
||||
use codex_install_context::InstallContext;
|
||||
use codex_network_proxy::ManagedNetworkSandboxContext;
|
||||
use codex_network_proxy::RemoteCredentialMasking;
|
||||
use codex_network_proxy::RemoteNetworkProxyLaunchConfig;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
@@ -151,10 +152,13 @@ fn unknown_executor_version() -> String {
|
||||
pub struct EnvironmentCapabilities {
|
||||
/// Whether `exec` accepts instructions for launching an executor-local network proxy.
|
||||
#[serde(default)]
|
||||
pub network_proxy_launch: bool,
|
||||
network_proxy_launch: bool,
|
||||
/// Whether the selected shell can mask executor environment credentials.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub credential_masking: bool,
|
||||
credential_masking: bool,
|
||||
/// Whether credential masking also supports configured providers.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
credential_masking_custom_providers: bool,
|
||||
/// Whether capability discovery applies the filesystem sandbox sent with each root.
|
||||
#[serde(default)]
|
||||
pub capability_discovery_sandbox: bool,
|
||||
@@ -187,6 +191,31 @@ pub struct EnvironmentCapabilities {
|
||||
pub linux_approved_root_write_preserves_restrictions: bool,
|
||||
}
|
||||
|
||||
impl EnvironmentCapabilities {
|
||||
/// Network proxy features available through this executor.
|
||||
pub fn network_proxy(&self) -> NetworkProxyCapabilities {
|
||||
NetworkProxyCapabilities {
|
||||
launch: self.network_proxy_launch,
|
||||
credential_masking: if !self.credential_masking {
|
||||
RemoteCredentialMasking::Disabled
|
||||
} else if self.credential_masking_custom_providers {
|
||||
RemoteCredentialMasking::WithConfiguredProviders
|
||||
} else {
|
||||
RemoteCredentialMasking::BuiltInOnly
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Features supported by an executor-local network proxy.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct NetworkProxyCapabilities {
|
||||
/// Whether `exec` accepts instructions for launching an executor-local network proxy.
|
||||
pub launch: bool,
|
||||
/// Credential masking support available through the selected shell.
|
||||
pub credential_masking: RemoteCredentialMasking,
|
||||
}
|
||||
|
||||
/// Status returned by an initialized exec-server connection.
|
||||
///
|
||||
/// The response is intentionally small today. New status details can be added
|
||||
@@ -286,6 +315,7 @@ impl EnvironmentInfo {
|
||||
capabilities: EnvironmentCapabilities {
|
||||
network_proxy_launch: true,
|
||||
credential_masking,
|
||||
credential_masking_custom_providers: credential_masking,
|
||||
capability_discovery_sandbox: true,
|
||||
capability_discovery_v2: true,
|
||||
environment_config_read: true,
|
||||
@@ -1260,6 +1290,7 @@ mod tests {
|
||||
use super::FsOpenParams;
|
||||
use super::FsReadFileParams;
|
||||
use super::HttpRequestParams;
|
||||
use super::NetworkProxyCapabilities;
|
||||
use super::ProcessId;
|
||||
use super::ProcessSandboxType;
|
||||
use super::ShellInfo;
|
||||
@@ -1270,6 +1301,7 @@ mod tests {
|
||||
use codex_network_proxy::ManagedNetworkSandboxContext;
|
||||
use codex_network_proxy::NetworkProxyAuditMetadata;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
use codex_network_proxy::RemoteCredentialMasking;
|
||||
use codex_network_proxy::RemoteNetworkProxyConfig;
|
||||
use codex_network_proxy::RemoteNetworkProxyLaunchConfig;
|
||||
use codex_protocol::config_types::WindowsSandboxProxySettingsMode;
|
||||
@@ -1508,6 +1540,7 @@ mod tests {
|
||||
EnvironmentCapabilities {
|
||||
network_proxy_launch: true,
|
||||
credential_masking: false,
|
||||
credential_masking_custom_providers: false,
|
||||
capability_discovery_sandbox: true,
|
||||
capability_discovery_v2: false,
|
||||
environment_config_read: false,
|
||||
@@ -1520,6 +1553,88 @@ mod tests {
|
||||
linux_approved_root_write_preserves_restrictions: false,
|
||||
}
|
||||
);
|
||||
|
||||
for (input_masking, expected_masking, output_masking) in [
|
||||
(
|
||||
serde_json::json!({}),
|
||||
RemoteCredentialMasking::Disabled,
|
||||
serde_json::json!({}),
|
||||
),
|
||||
(
|
||||
serde_json::json!({ "credentialMasking": true }),
|
||||
RemoteCredentialMasking::BuiltInOnly,
|
||||
serde_json::json!({ "credentialMasking": true }),
|
||||
),
|
||||
(
|
||||
serde_json::json!({
|
||||
"credentialMasking": true,
|
||||
"credentialMaskingCustomProviders": false,
|
||||
}),
|
||||
RemoteCredentialMasking::BuiltInOnly,
|
||||
serde_json::json!({ "credentialMasking": true }),
|
||||
),
|
||||
(
|
||||
serde_json::json!({
|
||||
"credentialMasking": true,
|
||||
"credentialMaskingCustomProviders": true,
|
||||
}),
|
||||
RemoteCredentialMasking::WithConfiguredProviders,
|
||||
serde_json::json!({
|
||||
"credentialMasking": true,
|
||||
"credentialMaskingCustomProviders": true,
|
||||
}),
|
||||
),
|
||||
(
|
||||
serde_json::json!({ "credentialMaskingCustomProviders": true }),
|
||||
RemoteCredentialMasking::Disabled,
|
||||
serde_json::json!({ "credentialMaskingCustomProviders": true }),
|
||||
),
|
||||
(
|
||||
serde_json::json!({
|
||||
"credentialMasking": false,
|
||||
"credentialMaskingCustomProviders": true,
|
||||
}),
|
||||
RemoteCredentialMasking::Disabled,
|
||||
serde_json::json!({ "credentialMaskingCustomProviders": true }),
|
||||
),
|
||||
] {
|
||||
for launch in [None, Some(false), Some(true)] {
|
||||
let mut input = serde_json::json!({
|
||||
"capabilityDiscoverySandbox": true,
|
||||
});
|
||||
if let Some(launch) = launch {
|
||||
input["networkProxyLaunch"] = launch.into();
|
||||
}
|
||||
input
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.extend(input_masking.as_object().unwrap().clone());
|
||||
let decoded: EnvironmentCapabilities = serde_json::from_value(input).unwrap();
|
||||
assert_eq!(
|
||||
decoded.network_proxy(),
|
||||
NetworkProxyCapabilities {
|
||||
launch: launch.unwrap_or_default(),
|
||||
credential_masking: expected_masking,
|
||||
}
|
||||
);
|
||||
let mut expected_wire = serde_json::json!({
|
||||
"networkProxyLaunch": launch.unwrap_or_default(),
|
||||
"capabilityDiscoverySandbox": true,
|
||||
"capabilityDiscoveryV2": false,
|
||||
"environmentConfigRead": false,
|
||||
"httpHeaderEnvVars": false,
|
||||
"sandboxedFileStreaming": false,
|
||||
"fileWriteStreaming": false,
|
||||
"shellSnapshotV2": false,
|
||||
"windowsMxc": false,
|
||||
});
|
||||
expected_wire
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.extend(output_masking.as_object().unwrap().clone());
|
||||
assert_eq!(serde_json::to_value(decoded).unwrap(), expected_wire);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -72,9 +72,8 @@ struct PreparedWindowsSandboxRequest {
|
||||
impl PreparedExecRequest {
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn credential_snapshot_proxy(&self, params: &ExecParams) -> Option<&NetworkProxy> {
|
||||
if !params.network_proxy.as_ref()?.proxy.credential_masking
|
||||
|| !supports_credential_snapshot(params)
|
||||
{
|
||||
let launch = params.network_proxy.as_ref()?;
|
||||
if !launch.proxy.credential_masking_enabled() || !supports_credential_snapshot(params) {
|
||||
return None;
|
||||
}
|
||||
self.network_proxy_handle.as_ref()?.proxy()
|
||||
|
||||
@@ -523,7 +523,9 @@ async fn native_request_handles_remote_proxy_config_for_platform(
|
||||
config.set_allowed_domains(vec!["allowed.example".to_string()]);
|
||||
let mut proxy_config = RemoteNetworkProxyConfig::from_effective_config(&config)
|
||||
.expect("supported remote proxy config");
|
||||
proxy_config.credential_masking = credential_masking;
|
||||
if credential_masking {
|
||||
proxy_config.enable_credential_masking(Default::default());
|
||||
}
|
||||
let params = ExecParams {
|
||||
metadata: Default::default(),
|
||||
process_id: ProcessId::from("process-remote-proxy"),
|
||||
@@ -627,7 +629,7 @@ async fn disabled_remote_proxy_config_is_rejected_before_exporting_ports() {
|
||||
let mut proxy_config =
|
||||
RemoteNetworkProxyConfig::from_effective_config(&NetworkProxyConfig::default())
|
||||
.expect("serializable disabled proxy config");
|
||||
proxy_config.credential_masking = true;
|
||||
proxy_config.enable_credential_masking(Default::default());
|
||||
let params = ExecParams {
|
||||
metadata: Default::default(),
|
||||
process_id: ProcessId::from("process-disabled-remote-proxy"),
|
||||
|
||||
@@ -8,6 +8,8 @@ mod tls_proxy;
|
||||
#[path = "exec_process/windows_sandbox.rs"]
|
||||
mod windows_sandbox;
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -522,7 +524,9 @@ async fn shell_snapshot_v2_remote_managed_proxy_uses_prepared_execution_context(
|
||||
const API_KEY: &str = "sk-proj-executor-only-0000000000000000000000000000000000";
|
||||
const PROFILE_GH_TOKEN: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const REQUEST_GH_TOKEN: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
const VENDOR_TOKEN: &str = "vendor_abcdefgh";
|
||||
const CUSTOM_HOST: &str = "chatgpt.com";
|
||||
let configured_provider = shell_name == "bash" && shell_args.first() == Some(&"-c");
|
||||
let shell_path = format!("/bin/{shell_name}");
|
||||
if shell_name == "zsh" && !std::path::Path::new(&shell_path).exists() {
|
||||
eprintln!("skipping zsh test: {shell_path} is unavailable");
|
||||
@@ -553,6 +557,12 @@ async fn shell_snapshot_v2_remote_managed_proxy_uses_prepared_execution_context(
|
||||
})
|
||||
.transpose()?;
|
||||
let mut executor_env = vec![("OPENAI_API_KEY", API_KEY.to_string())];
|
||||
if configured_provider {
|
||||
executor_env.extend([
|
||||
("VENDOR_TOKEN", VENDOR_TOKEN.to_string()),
|
||||
("VENDOR_BASE_URL", "https://api.openai.com/v1".to_string()),
|
||||
]);
|
||||
}
|
||||
if let Some(upstream) = &upstream {
|
||||
let ca_path = home.path().join("upstream-ca.pem");
|
||||
std::fs::write(&ca_path, &upstream.ca_cert_pem)?;
|
||||
@@ -681,6 +691,11 @@ async fn shell_snapshot_v2_remote_managed_proxy_uses_prepared_execution_context(
|
||||
.include_only
|
||||
.extend(["GH_TOKEN", "GH_ALIAS", "AUTH_SIZE_*"].map(str::to_string));
|
||||
}
|
||||
if configured_provider {
|
||||
policy
|
||||
.include_only
|
||||
.extend(["VENDOR_TOKEN", "VENDOR_BASE_URL"].map(str::to_string));
|
||||
}
|
||||
let mut proxy_settings = NetworkProxyConfig {
|
||||
enabled: true,
|
||||
..NetworkProxyConfig::default()
|
||||
@@ -691,7 +706,23 @@ async fn shell_snapshot_v2_remote_managed_proxy_uses_prepared_execution_context(
|
||||
}
|
||||
proxy_settings.set_allowed_domains(allowed_domains);
|
||||
let mut proxy_config = RemoteNetworkProxyConfig::from_effective_config(&proxy_settings)?;
|
||||
proxy_config.credential_masking = credential_masking;
|
||||
let mut providers = BTreeMap::new();
|
||||
if configured_provider {
|
||||
providers.insert(
|
||||
"vendor".to_string(),
|
||||
codex_network_proxy::CredentialProviderConfig {
|
||||
env: vec!["VENDOR_TOKEN".to_string()],
|
||||
patterns: vec!["vendor_[a-z]{8}".to_string()],
|
||||
url_prefix_from_env: Some("VENDOR_BASE_URL".to_string()),
|
||||
auth: vec![codex_network_proxy::CredentialAuthMethod::Header],
|
||||
header: Some("X-Vendor-Token".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
if credential_masking {
|
||||
proxy_config.enable_credential_masking(providers);
|
||||
}
|
||||
let mut saved_api_key = None;
|
||||
|
||||
for (attempt, &shell_args) in shell_args.iter().enumerate() {
|
||||
@@ -741,8 +772,13 @@ async fn shell_snapshot_v2_remote_managed_proxy_uses_prepared_execution_context(
|
||||
command
|
||||
};
|
||||
let command = if credential_masking {
|
||||
let vendor_header = if configured_provider {
|
||||
"--header \"X-Vendor-Token: $(<\"$HOME/vendor-token\")\""
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!(
|
||||
"{command}; test \"$GH_ENTERPRISE_TOKEN\" = executor-unbound-enterprise-token || exit 45; test \"$AUTH_ALIAS\" = \"Bearer $OPENAI_API_KEY\" || exit 42; printf '|%s' \"$OPENAI_API_KEY\"; curl --silent --show-error --fail --max-time 5 --noproxy '' --config \"$HOME/curl.conf\" \"${{OPENAI_BASE_URL:-https://api.openai.com/v1}}/models\" >/dev/null"
|
||||
"{command}; test \"$GH_ENTERPRISE_TOKEN\" = executor-unbound-enterprise-token || exit 45; test \"$AUTH_ALIAS\" = \"Bearer $OPENAI_API_KEY\" || exit 42; printf '|%s' \"$OPENAI_API_KEY\"; curl --silent --show-error --fail --max-time 5 --noproxy '' --config \"$HOME/curl.conf\" {vendor_header} \"${{OPENAI_BASE_URL:-https://api.openai.com/v1}}/models\" >/dev/null"
|
||||
)
|
||||
} else {
|
||||
format!("{command}; test \"$OPENAI_API_KEY\" = '{API_KEY}' || exit 46")
|
||||
@@ -761,6 +797,11 @@ async fn shell_snapshot_v2_remote_managed_proxy_uses_prepared_execution_context(
|
||||
} else {
|
||||
command
|
||||
};
|
||||
let command = if configured_provider && attempt == 0 {
|
||||
format!("printf '%s' \"$VENDOR_TOKEN\" > \"$HOME/vendor-token\"; {command}")
|
||||
} else {
|
||||
command
|
||||
};
|
||||
let started = timeout(
|
||||
Duration::from_secs(30),
|
||||
backend.start(ExecParams {
|
||||
@@ -833,6 +874,16 @@ async fn shell_snapshot_v2_remote_managed_proxy_uses_prepared_execution_context(
|
||||
format!("header = \"Authorization: Bearer {saved_api_key}\"\n")
|
||||
);
|
||||
let request = upstream.next_request()?;
|
||||
if configured_provider {
|
||||
let placeholder = std::fs::read_to_string(home.path().join("vendor-token"))?;
|
||||
assert!(placeholder.starts_with("vendor_"));
|
||||
assert_eq!(placeholder.len(), VENDOR_TOKEN.len());
|
||||
assert_ne!(placeholder, VENDOR_TOKEN);
|
||||
assert_eq!(
|
||||
request.headers.get("x-vendor-token"),
|
||||
Some(&vec![VENDOR_TOKEN.to_string()])
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
(
|
||||
request.connect_authority.as_str(),
|
||||
|
||||
@@ -107,6 +107,7 @@ pub use proxy::is_managed_proxy_env_var;
|
||||
pub use proxy::proxy_url_env_value;
|
||||
pub use proxy::strip_managed_proxy_env;
|
||||
pub use remote_config::RemoteCredentialBrokerCache;
|
||||
pub use remote_config::RemoteCredentialMasking;
|
||||
pub use remote_config::RemoteNetworkProxyConfig;
|
||||
pub use remote_config::RemoteNetworkProxyLaunchConfig;
|
||||
pub use request_cancellation::NetworkRequestCancellation;
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::http_proxy;
|
||||
use crate::network_policy::NetworkDecision;
|
||||
use crate::network_policy::NetworkDecisionSource;
|
||||
use crate::network_policy::NetworkPolicyDecider;
|
||||
use crate::remote_config::RemoteCredentialMasking;
|
||||
use crate::runtime::BlockedRequestObserver;
|
||||
use crate::runtime::ConfigState;
|
||||
use crate::runtime::HostAuthorization;
|
||||
@@ -949,15 +950,17 @@ impl NetworkProxy {
|
||||
pub async fn remote_launch_config(
|
||||
&self,
|
||||
local_binding_policy: crate::LocalBindingPolicy,
|
||||
credential_masking: bool,
|
||||
credential_masking: RemoteCredentialMasking,
|
||||
) -> Result<crate::RemoteNetworkProxyLaunchConfig> {
|
||||
let (mut config, brokerage_created_default_allowlist, _) =
|
||||
self.state.current_cfg_with_brokerage_provenance().await?;
|
||||
// Configured providers are not transported yet, so these commands run unmasked.
|
||||
let credential_masking = credential_masking
|
||||
&& config.enabled
|
||||
let credential_masking = config.enabled
|
||||
&& config.credential_broker
|
||||
&& config.credential_providers.is_empty();
|
||||
&& match credential_masking {
|
||||
RemoteCredentialMasking::Disabled => false,
|
||||
RemoteCredentialMasking::BuiltInOnly => config.credential_providers.is_empty(),
|
||||
RemoteCredentialMasking::WithConfiguredProviders => true,
|
||||
};
|
||||
anyhow::ensure!(
|
||||
!credential_masking
|
||||
|| (!config.dangerously_allow_plaintext_credential_injection
|
||||
@@ -1021,7 +1024,9 @@ impl NetworkProxy {
|
||||
"MXC cannot enforce allow_local_binding=false: native host-loopback access is bidirectional; use true or a different sandbox backend"
|
||||
);
|
||||
let mut proxy = crate::RemoteNetworkProxyConfig::from_effective_config(&config)?;
|
||||
proxy.credential_masking = credential_masking;
|
||||
if credential_masking {
|
||||
proxy.enable_credential_masking(config.credential_providers);
|
||||
}
|
||||
tracing::debug!(network_config = ?proxy, "resolved remote execution network configuration");
|
||||
let (environment_id, execution_id) = self
|
||||
.execution_scope
|
||||
@@ -1970,7 +1975,7 @@ mod tests {
|
||||
assert_eq!(proxy.current_cfg().await?, config);
|
||||
|
||||
let remote = proxy
|
||||
.remote_launch_config(DefaultFalse, /*credential_masking*/ false)
|
||||
.remote_launch_config(DefaultFalse, RemoteCredentialMasking::Disabled)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
(
|
||||
@@ -2068,7 +2073,7 @@ mod tests {
|
||||
owner_config.set_denied_domains(vec!["denied.example".to_string()]);
|
||||
assert_eq!(
|
||||
scoped
|
||||
.remote_launch_config(DefaultFalse, /*credential_masking*/ false)
|
||||
.remote_launch_config(DefaultFalse, RemoteCredentialMasking::Disabled)
|
||||
.await?
|
||||
.proxy
|
||||
.domains,
|
||||
@@ -2507,7 +2512,7 @@ mod tests {
|
||||
/*fallback_policy_decider*/ None,
|
||||
)?;
|
||||
let launch = scoped
|
||||
.remote_launch_config(RequireTrue, /*credential_masking*/ false)
|
||||
.remote_launch_config(RequireTrue, RemoteCredentialMasking::Disabled)
|
||||
.await?;
|
||||
let prepared = scoped.prepare_for_optional_environment(
|
||||
HashMap::from([(
|
||||
@@ -2522,7 +2527,7 @@ mod tests {
|
||||
assert!(!launch.proxy.enabled);
|
||||
assert!(
|
||||
scoped
|
||||
.remote_launch_config(DefaultFalse, /*credential_masking*/ true)
|
||||
.remote_launch_config(DefaultFalse, RemoteCredentialMasking::BuiltInOnly)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
@@ -2538,16 +2543,51 @@ mod tests {
|
||||
},
|
||||
);
|
||||
let custom_provider_proxy = NetworkProxy::builder()
|
||||
.state(Arc::new(network_proxy_state_for_policy(config)))
|
||||
.state(Arc::new(network_proxy_state_for_policy(config.clone())))
|
||||
.build()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
custom_provider_proxy
|
||||
.remote_launch_config(DefaultFalse, /*credential_masking*/ true)
|
||||
.remote_launch_config(DefaultFalse, RemoteCredentialMasking::BuiltInOnly)
|
||||
.await?
|
||||
.proxy,
|
||||
launch.proxy
|
||||
);
|
||||
assert!(
|
||||
custom_provider_proxy
|
||||
.remote_launch_config(
|
||||
DefaultFalse,
|
||||
RemoteCredentialMasking::WithConfiguredProviders
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
config.dangerously_allow_plaintext_credential_injection = false;
|
||||
let custom_provider_proxy = NetworkProxy::builder()
|
||||
.state(Arc::new(network_proxy_state_for_policy(config.clone())))
|
||||
.build()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
custom_provider_proxy
|
||||
.remote_launch_config(DefaultFalse, RemoteCredentialMasking::Disabled)
|
||||
.await?
|
||||
.proxy,
|
||||
launch.proxy
|
||||
);
|
||||
let custom_launch = custom_provider_proxy
|
||||
.remote_launch_config(
|
||||
DefaultFalse,
|
||||
RemoteCredentialMasking::WithConfiguredProviders,
|
||||
)
|
||||
.await?;
|
||||
assert!(custom_launch.proxy.credential_masking_enabled());
|
||||
assert_eq!(
|
||||
custom_launch
|
||||
.proxy
|
||||
.into_network_proxy_config()
|
||||
.credential_providers,
|
||||
config.credential_providers,
|
||||
);
|
||||
|
||||
let mut owner_config = NetworkProxyConfig::default();
|
||||
owner_config.set_allowed_domains(vec!["owner.example".to_string()]);
|
||||
@@ -2563,7 +2603,7 @@ mod tests {
|
||||
)?;
|
||||
assert!(
|
||||
owner_scoped
|
||||
.remote_launch_config(DefaultFalse, /*credential_masking*/ false)
|
||||
.remote_launch_config(DefaultFalse, RemoteCredentialMasking::Disabled)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
@@ -2595,13 +2635,16 @@ mod tests {
|
||||
.state(Arc::new(network_proxy_state_for_policy(config)))
|
||||
.build()
|
||||
.await?;
|
||||
for credential_masking in [false, true] {
|
||||
for (credential_masking, expected_masking) in [
|
||||
(RemoteCredentialMasking::Disabled, false),
|
||||
(RemoteCredentialMasking::BuiltInOnly, true),
|
||||
] {
|
||||
let launch = proxy
|
||||
.remote_launch_config(DefaultFalse, credential_masking)
|
||||
.await?;
|
||||
assert!(launch.proxy.enabled);
|
||||
assert_eq!(launch.proxy.mode, mode);
|
||||
assert_eq!(launch.proxy.credential_masking, credential_masking);
|
||||
assert_eq!(launch.proxy.credential_masking_enabled(), expected_masking);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
|
||||
@@ -2,8 +2,11 @@ use anyhow::Result;
|
||||
use anyhow::ensure;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::CredentialProviderConfig;
|
||||
use crate::NetworkDomainPermissions;
|
||||
use crate::NetworkMode;
|
||||
use crate::NetworkProxyAuditMetadata;
|
||||
@@ -69,6 +72,54 @@ impl RemoteCredentialBrokerCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Credential masking supported by an executor or requested for a remote launch.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum RemoteCredentialMasking {
|
||||
#[default]
|
||||
Disabled,
|
||||
/// Mask only when the configuration contains no custom providers.
|
||||
BuiltInOnly,
|
||||
WithConfiguredProviders,
|
||||
}
|
||||
|
||||
/// Masking fields shared with independently deployed executors.
|
||||
#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(default)]
|
||||
struct CredentialMaskingConfig {
|
||||
#[serde(
|
||||
rename = "credentialMasking",
|
||||
skip_serializing_if = "std::ops::Not::not"
|
||||
)]
|
||||
enabled: bool,
|
||||
#[serde(
|
||||
rename = "credentialProviders",
|
||||
skip_serializing_if = "BTreeMap::is_empty"
|
||||
)]
|
||||
providers: BTreeMap<String, CredentialProviderConfig>,
|
||||
}
|
||||
|
||||
impl CredentialMaskingConfig {
|
||||
fn deserialize_normalized<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Self, D::Error> {
|
||||
let mut config = Self::deserialize(deserializer)?;
|
||||
// Discard provider rules sent with masking off, even if we forward this configuration.
|
||||
if !config.enabled {
|
||||
config.providers.clear();
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for CredentialMaskingConfig {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("CredentialMaskingConfig")
|
||||
.field("enabled", &self.enabled)
|
||||
.field("credential_provider_count", &self.providers.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Executor-local proxy launch inputs transported with one process start.
|
||||
///
|
||||
/// Unlike [`crate::ManagedNetworkSandboxContext`], this describes how the executor should create
|
||||
@@ -115,15 +166,18 @@ impl RemoteNetworkProxyLaunchConfig {
|
||||
/// Effective network proxy settings that are safe to send to a remote executor.
|
||||
///
|
||||
/// Listener addresses are deliberately omitted because the executor chooses its own loopback
|
||||
/// ports. Masking carries only built-in broker activation; credentials, destination context, custom
|
||||
/// providers, CA configuration, and MITM hooks stay on their execution host.
|
||||
/// ports. Masking carries provider rules; credential values and destination context are resolved
|
||||
/// on the executor. CA configuration and MITM hooks stay on their execution host.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct RemoteNetworkProxyConfig {
|
||||
pub enabled: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub credential_masking: bool,
|
||||
#[serde(
|
||||
flatten,
|
||||
deserialize_with = "CredentialMaskingConfig::deserialize_normalized"
|
||||
)]
|
||||
credential_masking: CredentialMaskingConfig,
|
||||
pub enable_socks5: bool,
|
||||
pub enable_socks5_udp: bool,
|
||||
pub allow_upstream_proxy: bool,
|
||||
@@ -147,7 +201,7 @@ impl RemoteNetworkProxyConfig {
|
||||
);
|
||||
Ok(Self {
|
||||
enabled: config.enabled,
|
||||
credential_masking: false,
|
||||
credential_masking: CredentialMaskingConfig::default(),
|
||||
enable_socks5: config.enable_socks5,
|
||||
enable_socks5_udp: config.enable_socks5_udp,
|
||||
allow_upstream_proxy: config.allow_upstream_proxy,
|
||||
@@ -161,6 +215,22 @@ impl RemoteNetworkProxyConfig {
|
||||
})
|
||||
}
|
||||
|
||||
/// Enable masking with the given provider rules, or just the built-ins if empty.
|
||||
pub fn enable_credential_masking(
|
||||
&mut self,
|
||||
providers: BTreeMap<String, CredentialProviderConfig>,
|
||||
) {
|
||||
self.credential_masking = CredentialMaskingConfig {
|
||||
enabled: true,
|
||||
providers,
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether the executor should mask credentials for this launch.
|
||||
pub fn credential_masking_enabled(&self) -> bool {
|
||||
self.credential_masking.enabled
|
||||
}
|
||||
|
||||
pub(crate) fn into_network_proxy_config(self) -> NetworkProxyConfig {
|
||||
let mut config = NetworkProxyConfig {
|
||||
enabled: self.enabled,
|
||||
@@ -174,7 +244,10 @@ impl RemoteNetworkProxyConfig {
|
||||
allow_local_binding: Some(self.allow_local_binding),
|
||||
..NetworkProxyConfig::default()
|
||||
};
|
||||
config.set_credential_broker_enabled(self.credential_masking);
|
||||
if self.credential_masking.enabled {
|
||||
config.set_credential_broker_enabled(/*enabled*/ true);
|
||||
config.credential_providers = self.credential_masking.providers;
|
||||
}
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::NetworkProxyAuditMetadata;
|
||||
use crate::NetworkProxyConfig;
|
||||
use crate::NetworkProxyState;
|
||||
use crate::Platform;
|
||||
use crate::RemoteCredentialMasking;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
@@ -47,13 +48,14 @@ fn optional_socket_policy_preserves_input_and_resolves_at_remote_boundary() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn round_trip_preserves_supported_effective_settings() {
|
||||
for (executor_os, socket, credential_masking) in [
|
||||
(Platform::Linux, "/var/run/example.sock", false),
|
||||
(Platform::Linux, "/var/run/example.sock", true),
|
||||
(Platform::Macos, "/var/run/example.sock", false),
|
||||
(Platform::Macos, "/var/run/example.sock", true),
|
||||
(Platform::Windows, r"C:\example.sock", false),
|
||||
(Platform::Unknown, r"C:\example.sock", false),
|
||||
for (executor_os, socket, credential_masking, configured_provider) in [
|
||||
(Platform::Linux, "/var/run/example.sock", false, false),
|
||||
(Platform::Linux, "/var/run/example.sock", true, false),
|
||||
(Platform::Linux, "/var/run/example.sock", true, true),
|
||||
(Platform::Macos, "/var/run/example.sock", false, false),
|
||||
(Platform::Macos, "/var/run/example.sock", true, true),
|
||||
(Platform::Windows, r"C:\example.sock", false, false),
|
||||
(Platform::Unknown, r"C:\example.sock", false, false),
|
||||
] {
|
||||
let mut config = NetworkProxyConfig {
|
||||
enabled: true,
|
||||
@@ -65,14 +67,67 @@ async fn round_trip_preserves_supported_effective_settings() {
|
||||
allow_local_binding: Some(true),
|
||||
..NetworkProxyConfig::default()
|
||||
};
|
||||
config.set_allowed_domains(vec!["example.com".into()]);
|
||||
// Domain permissions serialize as a sorted map.
|
||||
config.set_denied_domains(vec!["blocked.example.com".into()]);
|
||||
config.set_allowed_domains(vec!["example.com".into()]);
|
||||
config.set_allow_unix_sockets(vec![socket.into()]);
|
||||
|
||||
let mut remote = RemoteNetworkProxyConfig::from_effective_config(&config)
|
||||
.expect("supported remote config");
|
||||
remote.credential_masking = credential_masking;
|
||||
config.set_credential_broker_enabled(credential_masking);
|
||||
if configured_provider {
|
||||
config.credential_providers.insert(
|
||||
"vendor".to_string(),
|
||||
crate::CredentialProviderConfig {
|
||||
env: vec!["VENDOR_TOKEN".to_string()],
|
||||
patterns: vec!["vendor_[a-z]{8}".to_string()],
|
||||
url_prefix_from_env: Some("VENDOR_BASE_URL".to_string()),
|
||||
auth: vec![crate::CredentialAuthMethod::Bearer],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
if credential_masking {
|
||||
remote.enable_credential_masking(config.credential_providers.clone());
|
||||
}
|
||||
let wire = serde_json::to_value(&remote).expect("serialize remote config");
|
||||
assert_eq!(
|
||||
wire.get("credentialMasking"),
|
||||
credential_masking.then_some(&serde_json::Value::Bool(true))
|
||||
);
|
||||
assert_eq!(
|
||||
wire.get("credentialProviders"),
|
||||
configured_provider
|
||||
.then(|| serde_json::to_value(&config.credential_providers).unwrap())
|
||||
.as_ref()
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<RemoteNetworkProxyConfig>(wire.clone())
|
||||
.expect("deserialize remote config"),
|
||||
remote
|
||||
);
|
||||
let mut disabled_wire = wire;
|
||||
disabled_wire
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("credentialMasking");
|
||||
let mut expected_wire = disabled_wire.clone();
|
||||
expected_wire
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("credentialProviders");
|
||||
// Ignore provider rules when the peer omits or explicitly disables masking.
|
||||
let mut explicitly_disabled_wire = disabled_wire.clone();
|
||||
explicitly_disabled_wire["credentialMasking"] = false.into();
|
||||
for wire in [disabled_wire, explicitly_disabled_wire] {
|
||||
let disabled: RemoteNetworkProxyConfig =
|
||||
serde_json::from_value(wire).expect("disabled masking ignores provider rules");
|
||||
assert!(!disabled.credential_masking_enabled());
|
||||
assert_eq!(serde_json::to_value(disabled).unwrap(), expected_wire);
|
||||
}
|
||||
let diagnostic = format!("{remote:?}");
|
||||
assert!(!diagnostic.contains("vendor_[a-z]{8}"));
|
||||
assert!(!diagnostic.contains("VENDOR_BASE_URL"));
|
||||
let round_trip = remote.clone().into_network_proxy_config();
|
||||
|
||||
assert_eq!(round_trip, config);
|
||||
@@ -102,7 +157,14 @@ async fn round_trip_preserves_supported_effective_settings() {
|
||||
);
|
||||
assert_eq!(
|
||||
proxy
|
||||
.remote_launch_config(DefaultFalse, credential_masking)
|
||||
.remote_launch_config(
|
||||
DefaultFalse,
|
||||
if credential_masking {
|
||||
RemoteCredentialMasking::WithConfiguredProviders
|
||||
} else {
|
||||
RemoteCredentialMasking::Disabled
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.proxy
|
||||
@@ -242,7 +304,7 @@ async fn remote_credential_mappings_follow_the_environment_and_broker_config() {
|
||||
..NetworkProxyConfig::default()
|
||||
})
|
||||
.unwrap();
|
||||
proxy.credential_masking = true;
|
||||
proxy.enable_credential_masking(Default::default());
|
||||
NetworkProxyState::from_remote_launch_config(
|
||||
RemoteNetworkProxyLaunchConfig::new(proxy)
|
||||
.for_execution(environment_id.to_string(), "execution".to_string())
|
||||
@@ -371,7 +433,7 @@ async fn local_binding_is_resolved_for_each_executor() -> anyhow::Result<()> {
|
||||
.await?;
|
||||
assert_eq!(
|
||||
proxy
|
||||
.remote_launch_config(policy, /*credential_masking*/ false)
|
||||
.remote_launch_config(policy, RemoteCredentialMasking::Disabled)
|
||||
.await
|
||||
.ok()
|
||||
.map(|launch| launch.proxy.allow_local_binding),
|
||||
|
||||
Reference in New Issue
Block a user