mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
core: expose permission profile to shell tools (#29941)
## tl;dr
Inject a `CODEX_PERMISSION_PROFILE` environment variable with the name
of the current permission profile when invoking a shell tool.
## Why
Shell tool owners may need to launch nested commands under the same
named permission profile, including through `codex sandbox -P PROFILE
--include-managed-config`. Until now, child processes could observe
sandbox and network metadata but could not identify the active named
permission profile.
The `--include-managed-config` flag is essential when a helper
reconstructs the sandbox from a profile name: it ensures the nested
sandbox also loads managed enterprise requirements. Without it, using
the inherited profile could unintentionally create a sandbox that does
not enforce the organization's managed restrictions.
The new environment value is intentionally informational and **must not
be treated as trusted input**. Any process in the ancestry can overwrite
an environment variable, so a consumer that passes this value to `codex
sandbox -P` must first validate it against the profiles that helper is
authorized to use.
## Example Use Case
Suppose an organization provides a trusted `remote-bash` wrapper that
lets Codex run a command on an approved build host. The local shell
command uses the named `:workspace` permission profile:
```toml
default_permissions = ":workspace"
```
The command exposed to the model is a small zsh wrapper. It deliberately
delegates with `exec`, preserving the original arguments and process
environment:
```zsh
#!/usr/bin/env zsh
exec /opt/codex-tools/remote_bash.py "$@"
```
The model invokes the public wrapper, not its Python implementation:
```sh
/opt/codex-tools/remote-bash \
--host builder.example.com \
-- printf '%s' 'hello world'
```
Only the inner implementation is authorized to escape the local sandbox:
```starlark
prefix_rule(
pattern=["/opt/codex-tools/remote_bash.py"],
decision="allow",
)
```
With zsh-fork, execution begins with `remote-bash` inside the
`:workspace` sandbox. When the wrapper calls `exec`, the exact prefix
rule matches `remote_bash.py`, so that inner script is restarted
unsandboxed. The escalated process inherits:
```text
CODEX_PERMISSION_PROFILE=:workspace
```
Inheritance does not make the value trustworthy. `remote_bash.py`
independently allowlists both the remote host and the permission profile
before using either value. In particular, a forged value such as
`:danger-full-access` is rejected before it can reach `codex sandbox
-P`:
```python
import argparse
import os
import shlex
import sys
ALLOWED_HOSTS = {"builder.example.com"}
ALLOWED_PROFILES = {":workspace"}
parser = argparse.ArgumentParser()
parser.add_argument("--host", required=True)
separator = sys.argv.index("--")
args = parser.parse_args(sys.argv[1:separator])
command = sys.argv[separator + 1:]
if args.host not in ALLOWED_HOSTS:
parser.error("host is not allowlisted")
if not command:
parser.error("the remote command must not be empty")
profile = os.environ.get("CODEX_PERMISSION_PROFILE")
if not profile:
raise SystemExit("CODEX_PERMISSION_PROFILE must not be empty")
if profile not in ALLOWED_PROFILES:
raise SystemExit("CODEX_PERMISSION_PROFILE is not allowlisted")
remote_command = shlex.join(command)
sandbox_command = shlex.join([
"codex", "sandbox", "-P", profile,
"--include-managed-config", "--",
"bash", "-lc", remote_command,
])
print(shlex.join(["ssh", args.host, sandbox_command]))
```
This builds each command layer as an argument vector and uses
`shlex.join()` at the boundary, rather than interpolating untrusted
shell text. After validation and parsing, the nested command has this
structure:
```text
ssh argv:
["ssh", "builder.example.com", SANDBOX_COMMAND]
SANDBOX_COMMAND argv:
["codex", "sandbox", "-P", ":workspace",
"--include-managed-config", "--",
"bash", "-lc", "printf %s 'hello world'"]
bash -lc payload argv:
["printf", "%s", "hello world"]
```
A production implementation could execute that SSH command. The
integration fixture prints it and parses the result back into arguments,
verifying the complete flow:
```text
model invokes outer wrapper
-> zsh-fork starts wrapper under :workspace
-> wrapper execs allowlisted Python script
-> prefix rule restarts Python script unsandboxed
-> Python script inherits CODEX_PERMISSION_PROFILE=:workspace
-> Python script verifies :workspace is allowlisted
-> remote command runs codex sandbox -P :workspace
with --include-managed-config
-> nested sandbox honors managed enterprise requirements
```
This gives the trusted helper access to resources outside the local
sandbox—such as SSH credentials—while ensuring that it can select only
an explicitly authorized profile and that work on the remote host
remains subject to the organization's managed requirements.
## What changed
- Inject `CODEX_PERMISSION_PROFILE` after shell environment policy
evaluation so the active profile wins over inherited or configured stale
values.
- Apply the variable to both `shell_command` and unified `exec_command`,
including local, zsh-fork, and remote exec-server paths.
- Remove stale values when the session has no active named profile.
- Preserve the current profile value when loading a shell snapshot so a
parent snapshot cannot restore an older profile.
## Testing
- Added classic-shell integration coverage proving an exact prefix rule
can run a `require_escalated` script outside the `:workspace` sandbox
while preserving `CODEX_PERMISSION_PROFILE=:workspace`.
- Added zsh-fork integration coverage in which the model invokes an
outer zsh wrapper, an inner allowlisted `remote_bash.py` runs
unsandboxed, and its printed SSH command reconstructs the inherited
`:workspace` sandbox with `--include-managed-config` while preserving
every argument after `--`.
- The example helper treats `CODEX_PERMISSION_PROFILE` as untrusted and
validates it against `ALLOWED_PROFILES` before constructing the nested
command.
- Assert that the reconstructed sandbox command includes
`--include-managed-config` so nested use of the inherited profile cannot
bypass managed enterprise requirements.
- Added coverage for overriding and removing stale profile values.
- Verified `shell_command` receives the selected active profile.
- Added shell snapshot coverage using `printenv
CODEX_PERMISSION_PROFILE`.
This commit is contained in:
committed by
GitHub
Unverified
parent
cc78903379
commit
c65cfeab14
@@ -2,11 +2,16 @@ use codex_protocol::ThreadId;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::config_types::EnvironmentVariablePattern;
|
||||
use codex_protocol::config_types::ShellEnvironmentPolicy;
|
||||
use codex_protocol::models::ActivePermissionProfile;
|
||||
use codex_protocol::shell_environment;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub use codex_protocol::shell_environment::CODEX_THREAD_ID_ENV_VAR;
|
||||
|
||||
/// Informational name of the active permission profile. Child processes can
|
||||
/// overwrite this value, so it must not be treated as proof of enforcement.
|
||||
pub const CODEX_PERMISSION_PROFILE_ENV_VAR: &str = "CODEX_PERMISSION_PROFILE";
|
||||
|
||||
/// Construct an environment map based on the rules in the specified policy. The
|
||||
/// resulting map can be passed directly to `Command::envs()` after calling
|
||||
/// `env_clear()` to ensure no unintended variables are leaked to the spawned
|
||||
@@ -25,6 +30,27 @@ pub fn create_env(
|
||||
shell_environment::create_env(policy, thread_id.as_deref())
|
||||
}
|
||||
|
||||
/// Injects the selected named permission profile into a shell tool's environment.
|
||||
///
|
||||
/// This is applied after the shell environment policy so the runtime-selected
|
||||
/// profile wins over inherited or configured values.
|
||||
pub(crate) fn inject_permission_profile_env(
|
||||
env: &mut HashMap<String, String>,
|
||||
active_permission_profile: Option<&ActivePermissionProfile>,
|
||||
) {
|
||||
if cfg!(windows) {
|
||||
env.retain(|key, _| !key.eq_ignore_ascii_case(CODEX_PERMISSION_PROFILE_ENV_VAR));
|
||||
} else {
|
||||
env.remove(CODEX_PERMISSION_PROFILE_ENV_VAR);
|
||||
}
|
||||
if let Some(active_permission_profile) = active_permission_profile {
|
||||
env.insert(
|
||||
CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(),
|
||||
active_permission_profile.id.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "windows"))]
|
||||
fn create_env_from_vars<I>(
|
||||
vars: I,
|
||||
|
||||
@@ -10,6 +10,59 @@ fn make_vars(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_permission_profile_env_overrides_policy_value() {
|
||||
let mut env = HashMap::from([(
|
||||
CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(),
|
||||
"stale-profile".to_string(),
|
||||
)]);
|
||||
|
||||
inject_permission_profile_env(
|
||||
&mut env,
|
||||
Some(&ActivePermissionProfile::new("current-profile")),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
env.get(CODEX_PERMISSION_PROFILE_ENV_VAR)
|
||||
.map(String::as_str),
|
||||
Some("current-profile")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_permission_profile_env_removes_stale_value_without_active_profile() {
|
||||
let mut env = HashMap::from([(
|
||||
CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(),
|
||||
"stale-profile".to_string(),
|
||||
)]);
|
||||
|
||||
inject_permission_profile_env(&mut env, /*active_permission_profile*/ None);
|
||||
|
||||
assert_eq!(env.get(CODEX_PERMISSION_PROFILE_ENV_VAR), None);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn inject_permission_profile_env_replaces_differently_cased_windows_key() {
|
||||
let mut env = HashMap::from([(
|
||||
"codex_permission_profile".to_string(),
|
||||
"stale-profile".to_string(),
|
||||
)]);
|
||||
|
||||
inject_permission_profile_env(
|
||||
&mut env,
|
||||
Some(&ActivePermissionProfile::new("current-profile")),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
env,
|
||||
HashMap::from([(
|
||||
CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(),
|
||||
"current-profile".to_string(),
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_core_inherit_defaults_keep_sensitive_vars() {
|
||||
let vars = make_vars(&[
|
||||
|
||||
@@ -6,6 +6,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use crate::exec::ExecCapturePolicy;
|
||||
use crate::exec::ExecParams;
|
||||
use crate::exec_env::create_env;
|
||||
use crate::exec_env::inject_permission_profile_env;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::maybe_emit_implicit_skill_invocation;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
@@ -99,15 +100,19 @@ impl ShellCommandHandler {
|
||||
let use_login_shell = Self::resolve_use_login_shell(params.login, allow_login_shell)?;
|
||||
let command = Self::base_command(shell, ¶ms.command, use_login_shell);
|
||||
|
||||
let mut env = create_env(
|
||||
&turn_context.config.permissions.shell_environment_policy,
|
||||
Some(session.thread_id),
|
||||
);
|
||||
let active_permission_profile = turn_context.config.permissions.active_permission_profile();
|
||||
inject_permission_profile_env(&mut env, active_permission_profile.as_ref());
|
||||
|
||||
Ok(ExecParams {
|
||||
command,
|
||||
cwd,
|
||||
expiration: params.timeout_ms.into(),
|
||||
capture_policy: ExecCapturePolicy::ShellTool,
|
||||
env: create_env(
|
||||
&turn_context.config.permissions.shell_environment_policy,
|
||||
Some(session.thread_id),
|
||||
),
|
||||
env,
|
||||
network: turn_context.network.clone(),
|
||||
network_environment_id: Some(turn_environment.environment_id.clone()),
|
||||
sandbox_permissions: params.sandbox_permissions.unwrap_or_default(),
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_protocol::models::ActivePermissionProfile;
|
||||
use codex_protocol::models::ShellCommandToolCallParams;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use crate::config::PermissionProfileSnapshot;
|
||||
use crate::exec_env::CODEX_PERMISSION_PROFILE_ENV_VAR;
|
||||
use crate::exec_env::create_env;
|
||||
use crate::exec_env::inject_permission_profile_env;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::session::step_context::StepContext;
|
||||
use crate::session::tests::make_session_and_context;
|
||||
@@ -71,7 +75,15 @@ fn assert_safe(shell: &Shell, command: &str) {
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_command_handler_to_exec_params_uses_selected_environment() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
let permission_profile = turn_context.config.permissions.permission_profile().clone();
|
||||
Arc::make_mut(&mut turn_context.config)
|
||||
.permissions
|
||||
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active(
|
||||
permission_profile,
|
||||
ActivePermissionProfile::new("test-profile"),
|
||||
))
|
||||
.expect("set active permission profile");
|
||||
|
||||
let command = "echo hello".to_string();
|
||||
let workdir = Some("subdir".to_string());
|
||||
@@ -99,10 +111,12 @@ async fn shell_command_handler_to_exec_params_uses_selected_environment() {
|
||||
PathUri::from_abs_path(&selected_cwd),
|
||||
Some(selected_shell),
|
||||
);
|
||||
let expected_env = create_env(
|
||||
let mut expected_env = create_env(
|
||||
&turn_context.config.permissions.shell_environment_policy,
|
||||
Some(session.thread_id),
|
||||
);
|
||||
let active_permission_profile = turn_context.config.permissions.active_permission_profile();
|
||||
inject_permission_profile_env(&mut expected_env, active_permission_profile.as_ref());
|
||||
|
||||
let params = ShellCommandToolCallParams {
|
||||
command,
|
||||
@@ -129,6 +143,12 @@ async fn shell_command_handler_to_exec_params_uses_selected_environment() {
|
||||
assert_eq!(exec_params.command, expected_command);
|
||||
assert_eq!(exec_params.cwd, expected_cwd);
|
||||
assert_eq!(exec_params.env, expected_env);
|
||||
assert_eq!(
|
||||
exec_params.env.get(CODEX_PERMISSION_PROFILE_ENV_VAR),
|
||||
active_permission_profile
|
||||
.as_ref()
|
||||
.map(|profile| &profile.id)
|
||||
);
|
||||
assert_eq!(exec_params.network, turn_context.network);
|
||||
assert_eq!(
|
||||
exec_params.network_environment_id.as_deref(),
|
||||
|
||||
@@ -4,6 +4,7 @@ Module: runtimes
|
||||
Concrete ToolRuntime implementations for specific tools. Each runtime stays
|
||||
small and focused and reuses the orchestrator for approvals + sandbox + retry.
|
||||
*/
|
||||
use crate::exec_env::CODEX_PERMISSION_PROFILE_ENV_VAR;
|
||||
use crate::exec_env::CODEX_THREAD_ID_ENV_VAR;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::shell::Shell;
|
||||
@@ -287,10 +288,14 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot(
|
||||
.map(|arg| format!(" '{}'", shell_single_quote(arg)))
|
||||
.collect::<String>();
|
||||
let mut override_env = explicit_env_overrides.clone();
|
||||
if let Some(thread_id) = env.get(CODEX_THREAD_ID_ENV_VAR) {
|
||||
override_env.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.clone());
|
||||
for key in [CODEX_THREAD_ID_ENV_VAR, CODEX_PERMISSION_PROFILE_ENV_VAR] {
|
||||
if let Some(value) = env.get(key) {
|
||||
override_env.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
let (override_captures, override_exports) = build_override_exports(&override_env);
|
||||
// Do not let a snapshot resurrect a stale profile when no named profile is active.
|
||||
let (override_captures, override_exports) =
|
||||
build_override_exports(&override_env, &[CODEX_PERMISSION_PROFILE_ENV_VAR]);
|
||||
let (proxy_captures, proxy_exports) = build_proxy_env_exports();
|
||||
let runtime_path_prepend_exports =
|
||||
runtime_path_prepends.shell_exports_after_snapshot(explicit_env_overrides);
|
||||
@@ -313,13 +318,18 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot(
|
||||
vec![shell_path.to_string(), "-c".to_string(), rewritten_script]
|
||||
}
|
||||
|
||||
fn build_override_exports(explicit_env_overrides: &HashMap<String, String>) -> (String, String) {
|
||||
fn build_override_exports(
|
||||
explicit_env_overrides: &HashMap<String, String>,
|
||||
restore_even_when_absent: &[&str],
|
||||
) -> (String, String) {
|
||||
let mut keys = explicit_env_overrides
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.chain(restore_even_when_absent.iter().copied())
|
||||
.filter(|key| is_valid_shell_variable_name(key))
|
||||
.collect::<Vec<_>>();
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
|
||||
build_override_exports_for_keys("__CODEX_SNAPSHOT_OVERRIDE", &keys)
|
||||
}
|
||||
|
||||
@@ -534,6 +534,78 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_codex_thread_id_from_env() {
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout), "nested-thread");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_wrap_shell_lc_with_snapshot_restores_permission_profile_from_env() {
|
||||
let dir = tempdir().expect("create temp dir");
|
||||
let snapshot_path = dir.path().join("snapshot.sh");
|
||||
std::fs::write(
|
||||
&snapshot_path,
|
||||
"# Snapshot file\nexport CODEX_PERMISSION_PROFILE='parent-profile'\n",
|
||||
)
|
||||
.expect("write snapshot");
|
||||
let (session_shell, shell_snapshot) =
|
||||
shell_with_snapshot(ShellType::Bash, "/bin/bash", snapshot_path.abs());
|
||||
let command = vec![
|
||||
"/bin/bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"printenv CODEX_PERMISSION_PROFILE".to_string(),
|
||||
];
|
||||
let env = HashMap::from([(
|
||||
CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(),
|
||||
"current-profile".to_string(),
|
||||
)]);
|
||||
let rewritten = maybe_wrap_shell_lc_with_snapshot(
|
||||
&command,
|
||||
&session_shell,
|
||||
Some(&shell_snapshot),
|
||||
&HashMap::new(),
|
||||
&env,
|
||||
&RuntimePathPrepends::default(),
|
||||
);
|
||||
let output = Command::new(&rewritten[0])
|
||||
.args(&rewritten[1..])
|
||||
.env(CODEX_PERMISSION_PROFILE_ENV_VAR, "current-profile")
|
||||
.output()
|
||||
.expect("run rewritten command");
|
||||
|
||||
assert!(output.status.success(), "command failed: {output:?}");
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout), "current-profile\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_wrap_shell_lc_with_snapshot_unsets_absent_permission_profile() {
|
||||
let dir = tempdir().expect("create temp dir");
|
||||
let snapshot_path = dir.path().join("snapshot.sh");
|
||||
std::fs::write(
|
||||
&snapshot_path,
|
||||
"# Snapshot file\nexport CODEX_PERMISSION_PROFILE='stale-profile'\n",
|
||||
)
|
||||
.expect("write snapshot");
|
||||
let (session_shell, shell_snapshot) =
|
||||
shell_with_snapshot(ShellType::Bash, "/bin/bash", snapshot_path.abs());
|
||||
let command = vec![
|
||||
"/bin/bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"printenv CODEX_PERMISSION_PROFILE".to_string(),
|
||||
];
|
||||
let rewritten = maybe_wrap_shell_lc_with_snapshot(
|
||||
&command,
|
||||
&session_shell,
|
||||
Some(&shell_snapshot),
|
||||
&HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&RuntimePathPrepends::default(),
|
||||
);
|
||||
let output = Command::new(&rewritten[0])
|
||||
.args(&rewritten[1..])
|
||||
.env_remove(CODEX_PERMISSION_PROFILE_ENV_VAR)
|
||||
.output()
|
||||
.expect("run rewritten command");
|
||||
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert_eq!(output.stdout, b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_wrap_shell_lc_with_snapshot_restores_proxy_env_from_process_env() {
|
||||
let dir = tempdir().expect("create temp dir");
|
||||
|
||||
@@ -13,8 +13,10 @@ use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::codex_thread::BackgroundTerminalInfo;
|
||||
use crate::exec_env::CODEX_PERMISSION_PROFILE_ENV_VAR;
|
||||
use crate::exec_env::CODEX_THREAD_ID_ENV_VAR;
|
||||
use crate::exec_env::create_env;
|
||||
use crate::exec_env::inject_permission_profile_env;
|
||||
use crate::exec_policy::ExecApprovalRequest;
|
||||
use crate::sandboxing::ExecOptions;
|
||||
use crate::sandboxing::ExecRequest;
|
||||
@@ -109,15 +111,19 @@ fn apply_unified_exec_env(mut env: HashMap<String, String>) -> HashMap<String, S
|
||||
fn exec_env_policy_from_shell_policy(
|
||||
policy: &ShellEnvironmentPolicy,
|
||||
) -> codex_exec_server::ExecEnvPolicy {
|
||||
let mut exclude = policy
|
||||
.exclude
|
||||
.iter()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
exclude.push(CODEX_PERMISSION_PROFILE_ENV_VAR.to_string());
|
||||
let mut r#set = policy.r#set.clone();
|
||||
r#set.retain(|key, _| !key.eq_ignore_ascii_case(CODEX_PERMISSION_PROFILE_ENV_VAR));
|
||||
codex_exec_server::ExecEnvPolicy {
|
||||
inherit: policy.inherit.clone(),
|
||||
ignore_default_excludes: policy.ignore_default_excludes,
|
||||
exclude: policy
|
||||
.exclude
|
||||
.iter()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect(),
|
||||
r#set: policy.r#set.clone(),
|
||||
exclude,
|
||||
r#set,
|
||||
include_only: policy
|
||||
.include_only
|
||||
.iter()
|
||||
@@ -132,7 +138,10 @@ fn env_overlay_for_exec_server(
|
||||
) -> HashMap<String, String> {
|
||||
request_env
|
||||
.iter()
|
||||
.filter(|(key, value)| local_policy_env.get(*key) != Some(*value))
|
||||
.filter(|(key, value)| {
|
||||
key.as_str() == CODEX_PERMISSION_PROFILE_ENV_VAR
|
||||
|| local_policy_env.get(*key) != Some(*value)
|
||||
})
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect()
|
||||
}
|
||||
@@ -1110,6 +1119,8 @@ impl UnifiedExecProcessManager {
|
||||
CODEX_THREAD_ID_ENV_VAR.to_string(),
|
||||
context.session.thread_id.to_string(),
|
||||
);
|
||||
let active_permission_profile = context.turn.config.permissions.active_permission_profile();
|
||||
inject_permission_profile_env(&mut env, active_permission_profile.as_ref());
|
||||
let env = apply_unified_exec_env(env);
|
||||
let exec_server_env_config = ExecServerEnvConfig {
|
||||
policy: exec_env_policy_from_shell_policy(
|
||||
|
||||
@@ -42,12 +42,20 @@ fn env_overlay_for_exec_server_keeps_runtime_changes_only() {
|
||||
("HOME".to_string(), "/client-home".to_string()),
|
||||
("PATH".to_string(), "/client-path".to_string()),
|
||||
("SHELL_SET".to_string(), "policy".to_string()),
|
||||
(
|
||||
CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(),
|
||||
"current-profile".to_string(),
|
||||
),
|
||||
]);
|
||||
let request_env = HashMap::from([
|
||||
("HOME".to_string(), "/client-home".to_string()),
|
||||
("PATH".to_string(), "/sandbox-path".to_string()),
|
||||
("SHELL_SET".to_string(), "policy".to_string()),
|
||||
("CODEX_THREAD_ID".to_string(), "thread-1".to_string()),
|
||||
(
|
||||
CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(),
|
||||
"current-profile".to_string(),
|
||||
),
|
||||
(
|
||||
"CODEX_SANDBOX_NETWORK_DISABLED".to_string(),
|
||||
"1".to_string(),
|
||||
@@ -59,6 +67,10 @@ fn env_overlay_for_exec_server_keeps_runtime_changes_only() {
|
||||
HashMap::from([
|
||||
("PATH".to_string(), "/sandbox-path".to_string()),
|
||||
("CODEX_THREAD_ID".to_string(), "thread-1".to_string()),
|
||||
(
|
||||
CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(),
|
||||
"current-profile".to_string(),
|
||||
),
|
||||
(
|
||||
"CODEX_SANDBOX_NETWORK_DISABLED".to_string(),
|
||||
"1".to_string()
|
||||
@@ -67,6 +79,31 @@ fn env_overlay_for_exec_server_keeps_runtime_changes_only() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_env_policy_excludes_runtime_permission_profile() {
|
||||
let policy = ShellEnvironmentPolicy {
|
||||
r#set: HashMap::from([
|
||||
(
|
||||
"codex_permission_profile".to_string(),
|
||||
"stale-profile".to_string(),
|
||||
),
|
||||
("KEEP".to_string(), "value".to_string()),
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
exec_env_policy_from_shell_policy(&policy),
|
||||
codex_exec_server::ExecEnvPolicy {
|
||||
inherit: policy.inherit,
|
||||
ignore_default_excludes: policy.ignore_default_excludes,
|
||||
exclude: vec![CODEX_PERMISSION_PROFILE_ENV_VAR.to_string()],
|
||||
r#set: HashMap::from([("KEEP".to_string(), "value".to_string())]),
|
||||
include_only: Vec::new(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() {
|
||||
let cwd: codex_utils_absolute_path::AbsolutePathBuf = std::env::current_dir()
|
||||
|
||||
@@ -10,6 +10,7 @@ use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
|
||||
use crate::test_codex::TestCodex;
|
||||
use crate::test_codex::TestCodexBuilder;
|
||||
use crate::test_codex::test_codex;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -19,12 +20,7 @@ pub struct ZshForkRuntime {
|
||||
}
|
||||
|
||||
impl ZshForkRuntime {
|
||||
fn apply_to_config(
|
||||
&self,
|
||||
config: &mut Config,
|
||||
approval_policy: AskForApproval,
|
||||
permission_profile: PermissionProfile,
|
||||
) {
|
||||
fn apply_to_config(&self, config: &mut Config, approval_policy: AskForApproval) {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::ShellTool)
|
||||
@@ -37,10 +33,6 @@ impl ZshForkRuntime {
|
||||
config.main_execve_wrapper_exe = Some(self.main_execve_wrapper_exe.clone());
|
||||
config.permissions.allow_login_shell = false;
|
||||
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
||||
config
|
||||
.permissions
|
||||
.set_permission_profile(permission_profile)
|
||||
.expect("set permission profile");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,10 +78,13 @@ pub async fn build_zsh_fork_test<F>(
|
||||
where
|
||||
F: FnOnce(&Path) + Send + 'static,
|
||||
{
|
||||
let mut builder = test_codex()
|
||||
let mut builder = zsh_fork_test_builder(runtime, approval_policy)
|
||||
.with_pre_build_hook(pre_build_hook)
|
||||
.with_config(move |config| {
|
||||
runtime.apply_to_config(config, approval_policy, permission_profile);
|
||||
config
|
||||
.permissions
|
||||
.set_permission_profile(permission_profile)
|
||||
.expect("set permission profile");
|
||||
});
|
||||
builder.build(server).await
|
||||
}
|
||||
@@ -104,10 +99,13 @@ pub async fn build_unified_exec_zsh_fork_test<F>(
|
||||
where
|
||||
F: FnOnce(&Path) + Send + 'static,
|
||||
{
|
||||
let mut builder = test_codex()
|
||||
let mut builder = zsh_fork_test_builder(runtime, approval_policy)
|
||||
.with_pre_build_hook(pre_build_hook)
|
||||
.with_config(move |config| {
|
||||
runtime.apply_to_config(config, approval_policy, permission_profile);
|
||||
config
|
||||
.permissions
|
||||
.set_permission_profile(permission_profile)
|
||||
.expect("set permission profile");
|
||||
config.use_experimental_unified_exec_tool = true;
|
||||
config
|
||||
.features
|
||||
@@ -121,6 +119,15 @@ where
|
||||
builder.build(server).await
|
||||
}
|
||||
|
||||
pub fn zsh_fork_test_builder(
|
||||
runtime: ZshForkRuntime,
|
||||
approval_policy: AskForApproval,
|
||||
) -> TestCodexBuilder {
|
||||
test_codex().with_config(move |config| {
|
||||
runtime.apply_to_config(config, approval_policy);
|
||||
})
|
||||
}
|
||||
|
||||
fn find_test_zsh_path() -> Result<Option<PathBuf>> {
|
||||
let repo_root = codex_utils_cargo_bin::repo_root()?;
|
||||
let dotslash_zsh = repo_root.join("codex-rs/app-server/tests/suite/zsh");
|
||||
|
||||
@@ -10,6 +10,7 @@ use codex_features::Feature;
|
||||
use codex_protocol::approvals::NetworkApprovalProtocol;
|
||||
use codex_protocol::approvals::NetworkPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyRuleAction;
|
||||
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
@@ -47,6 +48,7 @@ use core_test_support::wait_for_event_with_timeout;
|
||||
use core_test_support::zsh_fork::build_zsh_fork_test;
|
||||
use core_test_support::zsh_fork::restrictive_workspace_write_profile;
|
||||
use core_test_support::zsh_fork::zsh_fork_runtime;
|
||||
use core_test_support::zsh_fork::zsh_fork_test_builder;
|
||||
use pretty_assertions::assert_eq;
|
||||
use regex_lite::Regex;
|
||||
use serde_json::Value;
|
||||
@@ -131,6 +133,8 @@ enum ActionKind {
|
||||
|
||||
const DEFAULT_UNIFIED_EXEC_JUSTIFICATION: &str =
|
||||
"Requires escalated permissions to bypass the sandbox in tests.";
|
||||
const WORKSPACE_PERMISSION_PROFILE_CONFIG: &str = r#"default_permissions = ":workspace"
|
||||
"#;
|
||||
|
||||
impl ActionKind {
|
||||
fn policy_src(&self) -> Option<&'static str> {
|
||||
@@ -640,6 +644,7 @@ enum ScenarioGroup {
|
||||
UnifiedExec,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
struct CommandResult {
|
||||
exit_code: Option<i64>,
|
||||
stdout: String,
|
||||
@@ -683,6 +688,52 @@ async fn submit_turn(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn submit_turn_preserving_active_permission_profile(
|
||||
test: &TestCodex,
|
||||
prompt: &str,
|
||||
approval_policy: AskForApproval,
|
||||
) -> Result<()> {
|
||||
let session_model = test.session_configured.model.clone();
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: prompt.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
|
||||
environments: Some(local_selections(test.config.cwd.clone())),
|
||||
approval_policy: Some(approval_policy),
|
||||
approvals_reviewer: Some(ApprovalsReviewer::User),
|
||||
collaboration_mode: Some(codex_protocol::config_types::CollaborationMode {
|
||||
mode: codex_protocol::config_types::ModeKind::Default,
|
||||
settings: codex_protocol::config_types::Settings {
|
||||
model: session_model,
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
},
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assert_active_workspace_permission_profile(test: &TestCodex) {
|
||||
assert_eq!(
|
||||
test.session_configured
|
||||
.active_permission_profile
|
||||
.as_ref()
|
||||
.map(|profile| profile.id.as_str()),
|
||||
Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE)
|
||||
);
|
||||
}
|
||||
|
||||
fn parse_result(item: &Value) -> CommandResult {
|
||||
let output_str = item
|
||||
.get("output")
|
||||
@@ -2696,6 +2747,325 @@ async fn matched_prefix_rule_runs_unsandboxed_under_zsh_fork() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verifies that an allowlisted script retains the originating named profile
|
||||
/// when its shell tool call requests escalated, unsandboxed execution.
|
||||
///
|
||||
/// Tool owners use this pattern when a trusted wrapper must run outside the
|
||||
/// current sandbox, but then needs to launch child commands back inside the
|
||||
/// same sandbox with `codex sandbox -P`. The nested invocation must also pass
|
||||
/// `--include-managed-config` so it continues to honor enterprise requirements.
|
||||
/// The test proves both halves of that contract: the wrapper writes outside the
|
||||
/// `:workspace` sandbox, while its inherited profile name remains `:workspace`.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[cfg(unix)]
|
||||
async fn allowed_escalated_shell_command_inherits_active_permission_profile() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let home = Arc::new(TempDir::new()?);
|
||||
fs::write(
|
||||
home.path().join("config.toml"),
|
||||
WORKSPACE_PERMISSION_PROFILE_CONFIG,
|
||||
)?;
|
||||
|
||||
let script_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
|
||||
let script_path = script_dir.path().join("print-permission-profile.sh");
|
||||
let outside_path = script_dir.path().join("unsandboxed-marker");
|
||||
fs::write(
|
||||
&script_path,
|
||||
format!(
|
||||
r#"#!/bin/sh
|
||||
# Print the inherited profile so the test can verify that it reached this script.
|
||||
printenv CODEX_PERMISSION_PROFILE
|
||||
touch {outside_path:?}
|
||||
"#
|
||||
),
|
||||
)?;
|
||||
|
||||
let rules_dir = home.path().join("rules");
|
||||
fs::create_dir_all(&rules_dir)?;
|
||||
let script_pattern = serde_json::to_string(&script_path.to_string_lossy())?;
|
||||
fs::write(
|
||||
rules_dir.join("default.rules"),
|
||||
format!(r#"prefix_rule(pattern=["/bin/sh", {script_pattern}], decision="allow")"#),
|
||||
)?;
|
||||
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let mut builder = test_codex().with_home(home).with_config(move |config| {
|
||||
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
assert!(!outside_path.starts_with(test.config.cwd.as_path()));
|
||||
assert_active_workspace_permission_profile(&test);
|
||||
|
||||
let call_id = "allowed-escalated-shell-inherits-permission-profile";
|
||||
let command = format!("/bin/sh {script_path:?}");
|
||||
let event = shell_event(
|
||||
call_id,
|
||||
&command,
|
||||
/*timeout_ms*/ 5_000,
|
||||
SandboxPermissions::RequireEscalated,
|
||||
)?;
|
||||
let _ = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-escalated-profile-1"),
|
||||
event,
|
||||
ev_completed("resp-escalated-profile-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let results = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-escalated-profile-1", "done"),
|
||||
ev_completed("resp-escalated-profile-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_turn_preserving_active_permission_profile(
|
||||
&test,
|
||||
"run the allowed script with escalated permissions",
|
||||
approval_policy,
|
||||
)
|
||||
.await?;
|
||||
|
||||
wait_for_completion_without_approval(&test).await;
|
||||
|
||||
let result = parse_result(&results.single_request().function_call_output(call_id));
|
||||
assert_eq!(
|
||||
result,
|
||||
CommandResult {
|
||||
exit_code: Some(0),
|
||||
stdout: format!("{BUILT_IN_PERMISSION_PROFILE_WORKSPACE}\n"),
|
||||
},
|
||||
"the unsandboxed script should inherit CODEX_PERMISSION_PROFILE from the shell command"
|
||||
);
|
||||
assert!(
|
||||
outside_path.exists(),
|
||||
"allowed escalated script should run outside the :workspace sandbox"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verifies that zsh-fork applies an inner script's allow rule even when the
|
||||
/// model invokes an outer wrapper, and that the escalated script retains the
|
||||
/// named profile needed to reconstruct the original sandbox remotely without
|
||||
/// dropping managed enterprise requirements. The script treats the inherited
|
||||
/// environment value as untrusted and accepts only explicitly allowlisted
|
||||
/// profile names before passing one to `codex sandbox -P`.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[cfg(unix)]
|
||||
async fn zsh_fork_inner_allowed_script_inherits_active_permission_profile() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let Some(runtime) = zsh_fork_runtime("zsh-fork remote sandbox wrapper test")? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
const HOST: &str = "builder.example.com";
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let script_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
|
||||
let wrapper_path = script_dir.path().join("remote-bash");
|
||||
let remote_bash_path = script_dir.path().join("remote_bash.py");
|
||||
let outside_path = script_dir.path().join("remote-bash-unsandboxed-marker");
|
||||
let outside_path_literal = serde_json::to_string(&outside_path.to_string_lossy())?;
|
||||
fs::write(
|
||||
&remote_bash_path,
|
||||
format!(
|
||||
r#"#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
ALLOWED_HOSTS = ("builder.example.com",)
|
||||
ALLOWED_PROFILES = (":workspace",)
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Print an ssh command that recreates the current Codex sandbox remotely."
|
||||
)
|
||||
parser.add_argument("--host", required=True)
|
||||
try:
|
||||
separator = sys.argv.index("--")
|
||||
except ValueError:
|
||||
parser.error("the remote command must follow --")
|
||||
args = parser.parse_args(sys.argv[1:separator])
|
||||
args.command = sys.argv[separator + 1:]
|
||||
if not args.command:
|
||||
parser.error("the remote command must not be empty")
|
||||
if not re.fullmatch(r"[a-z0-9.-]+", args.host) or args.host not in ALLOWED_HOSTS:
|
||||
parser.error("host is not allowlisted")
|
||||
return args
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
profile_name = os.environ.get("CODEX_PERMISSION_PROFILE")
|
||||
if not profile_name:
|
||||
raise SystemExit("CODEX_PERMISSION_PROFILE must not be empty")
|
||||
if profile_name not in ALLOWED_PROFILES:
|
||||
raise SystemExit("CODEX_PERMISSION_PROFILE is not allowlisted")
|
||||
|
||||
shell_command = shlex.join(args.command)
|
||||
sandbox_command = shlex.join(
|
||||
[
|
||||
"codex",
|
||||
"sandbox",
|
||||
"-P",
|
||||
profile_name,
|
||||
"--include-managed-config",
|
||||
"--",
|
||||
"bash",
|
||||
"-lc",
|
||||
shell_command,
|
||||
]
|
||||
)
|
||||
print(shlex.join(["ssh", args.host, sandbox_command]))
|
||||
|
||||
# Test-only proof that this inner script was allowed to run unsandboxed.
|
||||
Path({outside_path_literal}).write_text("unsandboxed", encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
"#
|
||||
),
|
||||
)?;
|
||||
let remote_bash_exec = shlex::try_join([remote_bash_path.to_string_lossy().as_ref()])?;
|
||||
fs::write(
|
||||
&wrapper_path,
|
||||
format!(
|
||||
r#"#!/usr/bin/env zsh
|
||||
exec {remote_bash_exec} "$@"
|
||||
"#
|
||||
),
|
||||
)?;
|
||||
for path in [&wrapper_path, &remote_bash_path] {
|
||||
let mut permissions = fs::metadata(path)?.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(path, permissions)?;
|
||||
}
|
||||
|
||||
let remote_bash_pattern = serde_json::to_string(&remote_bash_path.to_string_lossy())?;
|
||||
let rules = format!(r#"prefix_rule(pattern=[{remote_bash_pattern}], decision="allow")"#);
|
||||
let server = start_mock_server().await;
|
||||
let mut builder =
|
||||
zsh_fork_test_builder(runtime, approval_policy).with_pre_build_hook(move |home| {
|
||||
fs::write(
|
||||
home.join("config.toml"),
|
||||
WORKSPACE_PERMISSION_PROFILE_CONFIG,
|
||||
)
|
||||
.expect("write config");
|
||||
let rules_dir = home.join("rules");
|
||||
fs::create_dir_all(&rules_dir).expect("create rules dir");
|
||||
fs::write(rules_dir.join("default.rules"), rules).expect("write rules");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
assert!(!outside_path.starts_with(test.config.cwd.as_path()));
|
||||
assert_active_workspace_permission_profile(&test);
|
||||
|
||||
let command = shlex::try_join([
|
||||
wrapper_path.to_string_lossy().as_ref(),
|
||||
"--host",
|
||||
HOST,
|
||||
"--",
|
||||
"printf",
|
||||
"%s",
|
||||
"hello world",
|
||||
])?;
|
||||
let call_id = "zsh-fork-remote-sandbox-wrapper";
|
||||
let event = shell_event(
|
||||
call_id,
|
||||
&command,
|
||||
/*timeout_ms*/ 30_000,
|
||||
SandboxPermissions::UseDefault,
|
||||
)?;
|
||||
let _ = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-zsh-fork-remote-wrapper-1"),
|
||||
event,
|
||||
ev_completed("resp-zsh-fork-remote-wrapper-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let results = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-zsh-fork-remote-wrapper-1", "done"),
|
||||
ev_completed("resp-zsh-fork-remote-wrapper-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_turn_preserving_active_permission_profile(
|
||||
&test,
|
||||
"run the remote sandbox wrapper",
|
||||
approval_policy,
|
||||
)
|
||||
.await?;
|
||||
wait_for_completion_without_approval(&test).await;
|
||||
|
||||
let result = parse_result(&results.single_request().function_call_output(call_id));
|
||||
assert_eq!(
|
||||
result.exit_code.unwrap_or(0),
|
||||
0,
|
||||
"the inner remote_bash.py script should run successfully: {}",
|
||||
result.stdout
|
||||
);
|
||||
let ssh_argv = shlex::split(result.stdout.trim()).context("parse printed ssh command")?;
|
||||
assert_eq!(
|
||||
ssh_argv.len(),
|
||||
3,
|
||||
"expected ssh HOST LONG_COMMAND, got: {}",
|
||||
result.stdout
|
||||
);
|
||||
assert_eq!(
|
||||
ssh_argv[..2],
|
||||
["ssh", HOST],
|
||||
"remote_bash.py should target only the allowlisted host"
|
||||
);
|
||||
let sandbox_argv = shlex::split(&ssh_argv[2]).context("parse remote sandbox command")?;
|
||||
assert_eq!(
|
||||
sandbox_argv.len(),
|
||||
9,
|
||||
"expected codex sandbox ... bash -lc CMD"
|
||||
);
|
||||
assert_eq!(
|
||||
sandbox_argv[..8],
|
||||
[
|
||||
"codex",
|
||||
"sandbox",
|
||||
"-P",
|
||||
BUILT_IN_PERMISSION_PROFILE_WORKSPACE,
|
||||
"--include-managed-config",
|
||||
"--",
|
||||
"bash",
|
||||
"-lc",
|
||||
],
|
||||
"remote_bash.py should use the allowlisted inherited profile and managed configuration to reconstruct the Codex sandbox"
|
||||
);
|
||||
let command_argv = shlex::split(&sandbox_argv[8]).context("parse remote bash command")?;
|
||||
assert_eq!(
|
||||
command_argv,
|
||||
["printf", "%s", "hello world"],
|
||||
"remote_bash.py should preserve every argument after --"
|
||||
);
|
||||
assert!(
|
||||
outside_path.exists(),
|
||||
"the inner allowlisted script should run outside the :workspace sandbox"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[cfg(unix)]
|
||||
async fn invalid_requested_prefix_rule_falls_back_for_compound_command() -> Result<()> {
|
||||
|
||||
Reference in New Issue
Block a user