Standardize shell execution on unified exec (#39757)

## What changed

- Remove the legacy `shell_command` handler and runtime, leaving `exec_command`
  and `write_stdin` as the shell execution tools.
- Treat legacy `shell_command` model metadata as `unified_exec`, and normalize
  legacy user opt-outs so they do not disable command execution. Managed feature
  requirements and `shell_tool` can still disable it.
- Preserve shell approvals, sandboxing, zsh-fork support, and output truncation
  through the unified execution path.

## Testing

- Cover legacy configuration and model-metadata compatibility.
- Exercise unified shell execution, approvals, truncation, and `apply_patch`
  serialization across the app-server and core test suites.

GitOrigin-RevId: 5c2fd6164fc3519cdae4944cb9db276b8467311c
This commit is contained in:
jif
2026-08-20 17:46:05 +00:00
committed by copyberry
parent d0cc662b8c
commit 8a40095ea3
116 changed files with 1338 additions and 4711 deletions
+76
View File
@@ -6445,6 +6445,50 @@ async fn legacy_toggles_map_to_features() -> std::io::Result<()> {
Ok(())
}
#[tokio::test]
async fn legacy_unified_exec_disable_flags_do_not_disable_command_execution() -> std::io::Result<()>
{
for cfg in [
ConfigToml {
features: Some(FeaturesToml::from(BTreeMap::from([(
"unified_exec".to_string(),
false,
)]))),
..Default::default()
},
ConfigToml {
experimental_use_unified_exec_tool: Some(false),
..Default::default()
},
] {
let codex_home = TempDir::new()?;
let mut config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert!(config.features.enabled(Feature::UnifiedExec));
assert!(config.features.enabled(Feature::ShellTool));
config
.features
.disable(Feature::UnifiedExec)
.expect("legacy unified-exec toggle should normalize successfully");
assert!(config.features.enabled(Feature::UnifiedExec));
config
.features
.disable(Feature::ShellTool)
.expect("shell tool should remain independently configurable");
assert!(!config.features.enabled(Feature::ShellTool));
assert!(config.features.enabled(Feature::UnifiedExec));
}
Ok(())
}
#[tokio::test]
async fn responses_websocket_features_do_not_change_wire_api() -> std::io::Result<()> {
for feature_key in ["responses_websockets", "responses_websockets_v2"] {
@@ -10977,6 +11021,38 @@ shell_tool = false
Ok(())
}
#[tokio::test]
async fn feature_requirements_can_still_disable_unified_exec() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"
[features]
unified_exec = false
shell_tool = true
"#,
),
)
.build()
.await?;
assert!(!config.features.enabled(Feature::UnifiedExec));
assert!(config.features.enabled(Feature::ShellTool));
config
.features
.enable(Feature::UnifiedExec)
.expect("managed feature mutations should normalize successfully");
assert!(!config.features.enabled(Feature::UnifiedExec));
assert!(config.features.enabled(Feature::ShellTool));
Ok(())
}
#[tokio::test]
async fn feature_requirements_auto_review_disables_guardian_approval() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -152,6 +152,12 @@ fn normalize_candidate(
mut candidate: Features,
pinned_features: &BTreeMap<Feature, bool>,
) -> Features {
// Legacy user opt-outs selected the removed shell backend. Only managed
// requirements may disable the remaining unified-exec implementation.
if !pinned_features.contains_key(&Feature::UnifiedExec) {
candidate.enable(Feature::UnifiedExec);
}
for (feature, enabled) in pinned_features {
candidate.set_enabled(*feature, *enabled);
}
+1
View File
@@ -205,6 +205,7 @@ impl ExecExpiration {
}
/// If ExecExpiration is a timeout, returns the timeout in milliseconds.
#[cfg(target_os = "windows")]
pub(crate) fn timeout_ms(&self) -> Option<u64> {
match self {
ExecExpiration::Timeout(duration) => Some(duration.as_millis() as u64),
+2 -39
View File
@@ -16,14 +16,6 @@ use super::prompt::guardian_truncate_text;
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum GuardianApprovalRequest {
Shell {
id: String,
command: Vec<String>,
cwd: AbsolutePathBuf,
sandbox_permissions: crate::sandboxing::SandboxPermissions,
additional_permissions: Option<AdditionalPermissionProfile>,
justification: Option<String>,
},
ExecCommand {
id: String,
command: Vec<String>,
@@ -264,22 +256,6 @@ pub(crate) fn guardian_approval_request_to_json(
action: &GuardianApprovalRequest,
) -> serde_json::Result<Value> {
match action {
GuardianApprovalRequest::Shell {
id: _,
command,
cwd,
sandbox_permissions,
additional_permissions,
justification,
} => serialize_command_guardian_action(
"shell",
command,
cwd,
*sandbox_permissions,
additional_permissions.as_ref(),
justification.as_ref(),
/*tty*/ None,
),
GuardianApprovalRequest::ExecCommand {
id: _,
command,
@@ -388,9 +364,6 @@ pub(crate) fn guardian_assessment_action(
action: &GuardianApprovalRequest,
) -> GuardianAssessmentAction {
match action {
GuardianApprovalRequest::Shell { command, cwd, .. } => {
command_assessment_action(GuardianCommandSource::Shell, command, cwd)
}
GuardianApprovalRequest::ExecCommand { command, cwd, .. } => {
command_assessment_action(GuardianCommandSource::UnifiedExec, command, cwd)
}
@@ -456,14 +429,6 @@ pub(crate) fn guardian_reviewed_action(
request: &GuardianApprovalRequest,
) -> GuardianReviewedAction {
match request {
GuardianApprovalRequest::Shell {
sandbox_permissions,
additional_permissions,
..
} => GuardianReviewedAction::Shell {
sandbox_permissions: *sandbox_permissions,
additional_permissions: additional_permissions.clone(),
},
GuardianApprovalRequest::ExecCommand {
sandbox_permissions,
additional_permissions,
@@ -514,8 +479,7 @@ pub(crate) fn guardian_reviewed_action(
pub(crate) fn guardian_request_target_item_id(request: &GuardianApprovalRequest) -> Option<&str> {
match request {
GuardianApprovalRequest::Shell { id, .. }
| GuardianApprovalRequest::ExecCommand { id, .. }
GuardianApprovalRequest::ExecCommand { id, .. }
| GuardianApprovalRequest::ApplyPatch { id, .. }
| GuardianApprovalRequest::McpToolCall { id, .. }
| GuardianApprovalRequest::RequestPermissions { id, .. } => Some(id),
@@ -532,8 +496,7 @@ pub(crate) fn guardian_request_turn_id<'a>(
match request {
GuardianApprovalRequest::NetworkAccess { turn_id, .. }
| GuardianApprovalRequest::RequestPermissions { turn_id, .. } => turn_id,
GuardianApprovalRequest::Shell { .. }
| GuardianApprovalRequest::ExecCommand { .. }
GuardianApprovalRequest::ExecCommand { .. }
| GuardianApprovalRequest::ApplyPatch { .. }
| GuardianApprovalRequest::McpToolCall { .. } => default_turn_id,
#[cfg(unix)]
+1 -2
View File
@@ -77,8 +77,7 @@ fn plugin_attribution_for_guardian_request(
request: &GuardianApprovalRequest,
) -> Option<PluginCommandAttribution> {
match request {
GuardianApprovalRequest::Shell { command, cwd, .. }
| GuardianApprovalRequest::ExecCommand { command, cwd, .. } => {
GuardianApprovalRequest::ExecCommand { command, cwd, .. } => {
turn.plugin_attribution_for_command(command, cwd)
}
#[cfg(unix)]
+2 -1
View File
@@ -1626,13 +1626,14 @@ mod tests {
parent_session: Arc::new(session),
parent_context: GuardianReviewContext::from(Arc::new(turn)),
spawn_config,
request: GuardianApprovalRequest::Shell {
request: GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
command: vec!["git".to_string(), "status".to_string()],
cwd,
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Inspect repo state.".to_string()),
tty: false,
},
reasons: ApprovalRequestReasons::default(),
schema: super::super::prompt::guardian_output_schema(),
@@ -22,7 +22,7 @@ Scenario: Guardian follow-up review request layout
[12] First retry reason\n\n
[13] Assess the exact planned action below. Use read-only tool checks when local state matters.\n
[14] Planned action JSON:\n
[15] {\n "command": [\n "git",\n "push"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the first docs fix.",\n "sandbox_permissions": "use_default",\n "tool": "shell"\n}\n
[15] {\n "command": [\n "git",\n "push"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the first docs fix.",\n "sandbox_permissions": "use_default",\n "tool": "exec_command",\n "tty": false\n}\n
[16] >>> APPROVAL REQUEST END\n
## Follow-up Guardian Review Request
@@ -43,7 +43,7 @@ Scenario: Guardian follow-up review request layout
[12] First retry reason\n\n
[13] Assess the exact planned action below. Use read-only tool checks when local state matters.\n
[14] Planned action JSON:\n
[15] {\n "command": [\n "git",\n "push"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the first docs fix.",\n "sandbox_permissions": "use_default",\n "tool": "shell"\n}\n
[15] {\n "command": [\n "git",\n "push"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the first docs fix.",\n "sandbox_permissions": "use_default",\n "tool": "exec_command",\n "tty": false\n}\n
[16] >>> APPROVAL REQUEST END\n
03:message/assistant:{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"first guardian rationale from the prior review"}
04:message/developer:Use prior reviews as context, not binding precedent. Follow the Workspace Policy. If the user explicitly approves a previously rejected action after being informed of the concrete risks, set outcome to "allow" unless the policy explicitly disallows user overwrites in such cases.
@@ -60,7 +60,7 @@ Scenario: Guardian follow-up review request layout
[10] Second retry reason\n\n
[11] Assess the exact planned action below. Use read-only tool checks when local state matters.\n
[12] Planned action JSON:\n
[13] {\n "command": [\n "git",\n "push",\n "--force-with-lease"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the second docs fix.",\n "sandbox_permissions": "use_default",\n "tool": "shell"\n}\n
[13] {\n "command": [\n "git",\n "push",\n "--force-with-lease"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the second docs fix.",\n "sandbox_permissions": "use_default",\n "tool": "exec_command",\n "tty": false\n}\n
[14] >>> APPROVAL REQUEST END\n
shared_prompt_cache_key: true
@@ -23,5 +23,5 @@ Scenario: Guardian review request layout
[13] Sandbox denied outbound git push to github.com.\n\n
[14] Assess the exact planned action below. Use read-only tool checks when local state matters.\n
[15] Planned action JSON:\n
[16] {\n "command": [\n "git",\n "push",\n "origin",\n "guardian-approval-mvp"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the reviewed docs fix to the repo remote.",\n "sandbox_permissions": "use_default",\n "tool": "shell"\n}\n
[16] {\n "command": [\n "git",\n "push",\n "origin",\n "guardian-approval-mvp"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the reviewed docs fix to the repo remote.",\n "sandbox_permissions": "use_default",\n "tool": "exec_command",\n "tty": false\n}\n
[17] >>> APPROVAL REQUEST END\n
+51 -30
View File
@@ -278,14 +278,15 @@ async fn guardian_test_session_turn_and_rx(
(session, turn, rx)
}
fn guardian_shell_request(id: &str) -> GuardianApprovalRequest {
GuardianApprovalRequest::Shell {
fn guardian_exec_command_request(id: &str) -> GuardianApprovalRequest {
GuardianApprovalRequest::ExecCommand {
id: id.to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the reviewed docs fix.".to_string()),
tty: false,
}
}
@@ -482,13 +483,14 @@ async fn build_guardian_prompt_full_mode_preserves_initial_review_format() -> an
let prompt = build_guardian_prompt_items(
session.as_ref(),
Some("Sandbox denied outbound git push to github.com.".to_string()),
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the reviewed docs fix.".to_string()),
tty: false,
},
GuardianPromptMode::Full,
)
@@ -518,13 +520,14 @@ async fn build_guardian_prompt_prefers_retry_reason_over_approval_reason() -> an
approval: Some("A policy rule requires approval.".to_string()),
retry: Some("The sandbox blocked the initial command.".to_string()),
},
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
tty: false,
},
GuardianPromptMode::Full,
/*reviewed_node_repl_evidence_sequence*/ 0,
@@ -556,13 +559,14 @@ async fn build_guardian_prompt_truncates_oversized_approval_reason() -> anyhow::
approval: Some(approval_reason),
retry: None,
},
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
tty: false,
},
GuardianPromptMode::Full,
/*reviewed_node_repl_evidence_sequence*/ 0,
@@ -646,13 +650,14 @@ async fn build_guardian_prompt_includes_parent_turn_denied_reads() -> anyhow::Re
approval: None,
retry: Some("Sandbox denied reading /repo/private/secret.txt.".to_string()),
},
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
command: vec!["cat".to_string(), "/repo/private/secret.txt".to_string()],
cwd: test_path_buf("/repo").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::RequireEscalated,
additional_permissions: None,
justification: Some("Need to inspect the secret file.".to_string()),
tty: false,
},
GuardianPromptMode::Full,
/*reviewed_node_repl_evidence_sequence*/ 0,
@@ -702,13 +707,14 @@ async fn build_guardian_prompt_delta_mode_preserves_original_numbering() -> anyh
let prompt = build_guardian_prompt_items(
session.as_ref(),
/*retry_reason*/ None,
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-2".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the second docs fix.".to_string()),
tty: false,
},
GuardianPromptMode::Delta {
cursor: GuardianTranscriptCursor {
@@ -740,13 +746,14 @@ async fn build_guardian_prompt_delta_mode_handles_empty_delta() -> anyhow::Resul
let prompt = build_guardian_prompt_items(
session.as_ref(),
/*retry_reason*/ None,
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-2".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the second docs fix.".to_string()),
tty: false,
},
GuardianPromptMode::Delta {
cursor: GuardianTranscriptCursor {
@@ -775,13 +782,14 @@ async fn build_guardian_prompt_stale_delta_cursor_falls_back_to_full_prompt() ->
let prompt = build_guardian_prompt_items(
session.as_ref(),
/*retry_reason*/ None,
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-3".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the docs fix.".to_string()),
tty: false,
},
GuardianPromptMode::Delta {
cursor: GuardianTranscriptCursor {
@@ -860,13 +868,14 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() -
let prompt = build_guardian_prompt_items(
session.as_ref(),
/*retry_reason*/ None,
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-4".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push after the compaction.".to_string()),
tty: false,
},
GuardianPromptMode::Delta {
cursor: GuardianTranscriptCursor {
@@ -1167,7 +1176,7 @@ async fn build_guardian_prompt_items_keeps_other_requests_generic() -> anyhow::R
guardian_mcp_request("node_repl", "js"),
guardian_mcp_request("node_repl", "inspect"),
guardian_mcp_request("another_server", "js"),
guardian_shell_request("shell-1"),
guardian_exec_command_request("shell-1"),
] {
let prompt = build_guardian_prompt_items_with_parent_turn(
session.as_ref(),
@@ -1732,13 +1741,14 @@ async fn guardian_request_model_for_auto_review(
let (outcome, analytics_result) = run_guardian_review_session_for_test(
Arc::clone(&session),
turn,
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
tty: false,
},
ApprovalRequestReasons {
approval: None,
@@ -1970,7 +1980,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot()
)
.await;
let request = GuardianApprovalRequest::Shell {
let request = GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
command: vec![
"git".to_string(),
@@ -1982,6 +1992,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot()
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the reviewed docs fix to the repo remote.".to_string()),
tty: false,
};
let outcome = run_guardian_review_session_for_test(
@@ -2107,13 +2118,14 @@ async fn build_guardian_prompt_items_includes_parent_session_id() -> anyhow::Res
let prompt = build_guardian_prompt_items(
&session,
/*retry_reason*/ None,
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
command: vec!["git".to_string(), "status".to_string()],
cwd: test_path_buf("/repo").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
tty: false,
},
GuardianPromptMode::Full,
)
@@ -2198,13 +2210,14 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
turn_mut.config = Arc::new(config);
seed_guardian_parent_history(&session, &turn).await;
let first_request = GuardianApprovalRequest::Shell {
let first_request = GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the first docs fix.".to_string()),
tty: false,
};
let first_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
@@ -2244,7 +2257,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
],
)
.await;
let second_request = GuardianApprovalRequest::Shell {
let second_request = GuardianApprovalRequest::ExecCommand {
id: "shell-2".to_string(),
command: vec![
"git".to_string(),
@@ -2255,6 +2268,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the second docs fix.".to_string()),
tty: false,
};
let second_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
@@ -2317,13 +2331,14 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
/*reference_context_item*/ None,
)
.await;
let third_request = GuardianApprovalRequest::Shell {
let third_request = GuardianApprovalRequest::ExecCommand {
id: "shell-3".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the third docs fix.".to_string()),
tty: false,
};
let third_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
@@ -2355,7 +2370,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
let fourth_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
guardian_shell_request("shell-4"),
guardian_exec_command_request("shell-4"),
ApprovalRequestReasons::default(),
guardian_output_schema(),
/*external_cancel*/ None,
@@ -2564,13 +2579,14 @@ async fn guardian_reused_trunk_ignores_stale_prior_turn_completion() -> anyhow::
let first_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the first docs fix.".to_string()),
tty: false,
},
ApprovalRequestReasons::default(),
guardian_output_schema(),
@@ -2609,13 +2625,14 @@ async fn guardian_reused_trunk_ignores_stale_prior_turn_completion() -> anyhow::
let second_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-2".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the second docs fix.".to_string()),
tty: false,
},
ApprovalRequestReasons::default(),
guardian_output_schema(),
@@ -2689,13 +2706,14 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() ->
&session,
&turn,
"review-shell-guardian-error".to_string(),
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-guardian-error".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the reviewed docs fix.".to_string()),
tty: false,
},
ApprovalRequestReasons::default(),
)
@@ -2782,7 +2800,7 @@ async fn guardian_review_retries_transient_session_failure_then_approves() -> an
let (outcome, metadata) = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
guardian_shell_request("shell-session-retry"),
guardian_exec_command_request("shell-session-retry"),
ApprovalRequestReasons::default(),
guardian_output_schema(),
/*external_cancel*/ None,
@@ -2824,7 +2842,7 @@ async fn guardian_review_does_not_retry_missing_assessment_payload() -> anyhow::
&session,
&turn,
"review-missing-assessment".to_string(),
guardian_shell_request("shell-missing-assessment"),
guardian_exec_command_request("shell-missing-assessment"),
ApprovalRequestReasons::default(),
)
.await;
@@ -2873,7 +2891,7 @@ async fn guardian_review_retries_two_parse_failures_then_approves() -> anyhow::R
let (outcome, metadata) = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
guardian_shell_request("shell-parse-retry"),
guardian_exec_command_request("shell-parse-retry"),
ApprovalRequestReasons::default(),
guardian_output_schema(),
/*external_cancel*/ None,
@@ -2928,7 +2946,7 @@ async fn guardian_review_exhausts_three_failures_with_one_terminal_event() -> an
&session,
&turn,
"review-exhausted-retry".to_string(),
guardian_shell_request("shell-exhausted-retry"),
guardian_exec_command_request("shell-exhausted-retry"),
ApprovalRequestReasons::default(),
)
.await;
@@ -2979,7 +2997,7 @@ async fn guardian_review_does_not_retry_valid_denial() -> anyhow::Result<()> {
&session,
&turn,
"review-valid-denial".to_string(),
guardian_shell_request("shell-valid-denial"),
guardian_exec_command_request("shell-valid-denial"),
ApprovalRequestReasons::default(),
)
.await;
@@ -3039,7 +3057,7 @@ async fn escalated_retry_bypasses_extension_approval_and_runs_guardian() -> anyh
&session,
&turn,
"review-escalated-retry".to_string(),
guardian_shell_request("shell-escalated-retry"),
guardian_exec_command_request("shell-escalated-retry"),
ApprovalRequestReasons {
approval: None,
retry: Some(retry_reason.to_string()),
@@ -3136,13 +3154,14 @@ async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() ->
let (session, turn) = guardian_test_session_and_turn_with_base_url(server.uri()).await;
seed_guardian_parent_history(&session, &turn).await;
let initial_request = GuardianApprovalRequest::Shell {
let initial_request = GuardianApprovalRequest::ExecCommand {
id: "shell-guardian-1".to_string(),
command: vec!["git".to_string(), "status".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Inspect repo state before proceeding.".to_string()),
tty: false,
};
assert_eq!(
review_approval_request(
@@ -3179,21 +3198,23 @@ async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() ->
)
.await;
let second_request = GuardianApprovalRequest::Shell {
let second_request = GuardianApprovalRequest::ExecCommand {
id: "shell-guardian-2".to_string(),
command: vec!["git".to_string(), "diff".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Inspect pending changes before proceeding.".to_string()),
tty: false,
};
let third_request = GuardianApprovalRequest::Shell {
let third_request = GuardianApprovalRequest::ExecCommand {
id: "shell-guardian-3".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Inspect whether pushing is safe before proceeding.".to_string()),
tty: false,
};
let session_for_second = Arc::clone(&session);
-4
View File
@@ -4,7 +4,6 @@ use crate::tools::flat_tool_name;
use crate::tools::handlers::unified_exec::ExecCommandArgs;
use codex_memories_read::usage::MEMORIES_USAGE_METRIC;
use codex_memories_read::usage::memories_usage_kinds_from_command;
use codex_protocol::models::ShellCommandToolCallParams;
pub(crate) fn emit_metric_for_tool_read(invocation: &ToolInvocation, success: bool) {
let Some(command) = shell_script_for_invocation(invocation) else {
@@ -36,9 +35,6 @@ pub(crate) fn shell_script_for_invocation(invocation: &ToolInvocation) -> Option
}
match invocation.tool_name.name.as_str() {
"shell_command" => serde_json::from_str::<ShellCommandToolCallParams>(arguments)
.ok()
.map(|params| params.command),
"exec_command" => serde_json::from_str::<ExecCommandArgs>(arguments)
.ok()
.map(|params| params.cmd),
+4 -74
View File
@@ -1257,7 +1257,7 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an
_req: &TurnEnvironment,
call_id: &str,
) -> std::io::Result<crate::tools::sandboxing::ApprovalAction> {
Ok(crate::tools::sandboxing::ApprovalAction::Shell {
Ok(crate::tools::sandboxing::ApprovalAction::ExecCommand {
id: call_id.to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: Vec::new(),
@@ -1266,6 +1266,7 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
tty: false,
proposed_execpolicy_amendment: None,
})
}
@@ -11089,7 +11090,7 @@ async fn fatal_tool_error_stops_turn_and_reports_error() {
id: None,
status: None,
call_id: "call-1".to_string(),
name: "shell_command".to_string(),
name: "exec_command".to_string(),
namespace: None,
input: "{}".to_string(),
internal_chat_message_metadata_passthrough: None,
@@ -11116,7 +11117,7 @@ async fn fatal_tool_error_stops_turn_and_reports_error() {
FunctionCallError::Fatal(message) => {
assert_eq!(
message,
"tool shell_command invoked with incompatible payload"
"tool exec_command invoked with incompatible payload"
);
}
other => panic!("expected FunctionCallError::Fatal, got {other:?}"),
@@ -11300,77 +11301,6 @@ async fn sample_rollout(
)
}
#[cfg(unix)]
#[tokio::test]
async fn shell_tool_cancellation_waits_for_runtime_cleanup() -> anyhow::Result<()> {
let session = make_session_with_config(|config| {
let cwd = config.cwd.clone();
config
.permissions
.set_legacy_sandbox_policy(SandboxPolicy::DangerFullAccess, cwd.as_path())
.expect("test setup should allow sandbox policy");
})
.await?;
let turn_context = session.new_default_turn().await;
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let temp_dir = tempfile::TempDir::new()?;
let ready_marker = temp_dir.path().join("ready");
let cleanup_marker = temp_dir.path().join("cleanup");
// Interrupt after the shell starts, then verify dispatch waits for its TERM cleanup trap.
let command = format!(
r#"trap 'printf cleaned > "{}"; exit 0' TERM
printf ready > "{}"
while :; do sleep 1; done"#,
cleanup_marker.display(),
ready_marker.display(),
);
let item = ResponseItem::FunctionCall {
id: None,
name: "shell_command".to_string(),
namespace: None,
arguments: serde_json::json!({
"command": command,
"timeout_ms": 60_000,
})
.to_string(),
call_id: "shell-cleanup-call".to_string(),
encrypted_function_args: None,
internal_chat_message_metadata_passthrough: None,
};
let call = ToolRouter::build_tool_call(item)?
.expect("shell command response item should build a tool call");
let cancellation_token = CancellationToken::new();
let cancellation_tx = cancellation_token.clone();
let handle = tokio::spawn(
test_tool_runtime(Arc::clone(&session), Arc::clone(&turn_context))
.handle_tool_call(call, cancellation_token),
);
let mut ready = false;
for _ in 0..50 {
if ready_marker.exists() {
ready = true;
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
if !ready {
cancellation_tx.cancel();
let _ = timeout(Duration::from_secs(5), handle).await;
anyhow::bail!("shell command should reach the ready marker");
}
cancellation_tx.cancel();
timeout(Duration::from_secs(5), handle)
.await
.expect("cancelled shell tool should finish promptly")
.expect("shell tool task should join")
.expect("cancelled shell tool should return a response item");
assert_eq!(std::fs::read_to_string(cleanup_marker)?, "cleaned");
Ok(())
}
#[tokio::test]
async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() {
use crate::sandboxing::SandboxPermissions;
@@ -308,7 +308,7 @@ async fn request_permissions_guardian_review_stops_when_cancelled() {
}
#[tokio::test]
async fn guardian_allows_shell_command_additional_permissions_requests_past_policy_validation() {
async fn guardian_allows_exec_command_additional_permissions_requests_past_policy_validation() {
let server = start_mock_server().await;
let _request_log = mount_sse_once(
&server,
@@ -371,11 +371,9 @@ async fn guardian_allows_shell_command_additional_permissions_requests_past_poli
);
let session = Arc::new(session);
let turn_context = Arc::new(turn_context_raw);
let expiration_ms: u64 = if cfg!(windows) { 2_500 } else { 1_000 };
let yield_time_ms: u64 = 10_000;
let handler = crate::tools::handlers::ShellCommandHandler::from(
codex_tools::ShellCommandBackendConfig::Classic,
);
let handler = crate::tools::handlers::ExecCommandHandler::default();
#[allow(deprecated)]
let workdir = Some(turn_context.cwd.to_string_lossy().to_string());
let step_context = StepContext::for_test(Arc::clone(&turn_context));
@@ -387,14 +385,14 @@ async fn guardian_allows_shell_command_additional_permissions_requests_past_poli
cancellation_token: CancellationToken::new(),
tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
call_id: "test-call".to_string(),
tool_name: codex_tools::ToolName::plain("shell_command"),
tool_name: codex_tools::ToolName::plain("exec_command"),
source: crate::tools::context::ToolCallSource::Direct,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"command": "echo hi",
"cmd": "echo hi",
"login": false,
"workdir": workdir,
"timeout_ms": expiration_ms,
"yield_time_ms": yield_time_ms,
"sandbox_permissions": SandboxPermissions::WithAdditionalPermissions,
"additional_permissions": PermissionProfile {
network: Some(NetworkPermissions {
@@ -414,7 +412,7 @@ async fn guardian_allows_shell_command_additional_permissions_requests_past_poli
}
#[tokio::test]
async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_skip() {
async fn strict_auto_review_turn_grant_forces_guardian_for_exec_command_policy_skip() {
let server = start_mock_server().await;
let guardian_request_log = mount_sse_once(
&server,
@@ -501,9 +499,7 @@ async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_
)
.await;
let handler = crate::tools::handlers::ShellCommandHandler::from(
codex_tools::ShellCommandBackendConfig::Classic,
);
let handler = crate::tools::handlers::ExecCommandHandler::default();
#[allow(deprecated)]
let workdir = Some(turn_context.cwd.to_string_lossy().to_string());
let step_context = StepContext::for_test(Arc::clone(&turn_context));
@@ -515,14 +511,14 @@ async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_
cancellation_token: CancellationToken::new(),
tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
call_id: "strict-shell-command-call".to_string(),
tool_name: codex_tools::ToolName::plain("shell_command"),
tool_name: codex_tools::ToolName::plain("exec_command"),
source: ToolCallSource::Direct,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"command": "echo hi",
"cmd": "echo hi",
"login": false,
"workdir": workdir,
"timeout_ms": 1_000_u64,
"yield_time_ms": 10_000_u64,
})
.to_string(),
},
@@ -665,7 +661,7 @@ async fn process_compacted_history_preserves_separate_guardian_developer_message
clippy::await_holding_invalid_type,
reason = "test mutates active turn state directly to seed granted permissions"
)]
async fn shell_command_allows_sticky_turn_permissions_without_inline_request_permissions_feature() {
async fn exec_command_allows_sticky_turn_permissions_without_inline_request_permissions_feature() {
let (mut session, turn_context_raw) = make_session_and_context().await;
session
.features
@@ -690,9 +686,7 @@ async fn shell_command_allows_sticky_turn_permissions_without_inline_request_per
let session = Arc::new(session);
let turn_context = Arc::new(turn_context_raw);
let handler = crate::tools::handlers::ShellCommandHandler::from(
codex_tools::ShellCommandBackendConfig::Classic,
);
let handler = crate::tools::handlers::ExecCommandHandler::default();
#[allow(deprecated)]
let workdir = Some(turn_context.cwd.to_string_lossy().to_string());
let step_context = StepContext::for_test(Arc::clone(&turn_context));
@@ -704,13 +698,13 @@ async fn shell_command_allows_sticky_turn_permissions_without_inline_request_per
cancellation_token: CancellationToken::new(),
tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
call_id: "sticky-turn-grant".to_string(),
tool_name: codex_tools::ToolName::plain("shell_command"),
tool_name: codex_tools::ToolName::plain("exec_command"),
source: crate::tools::context::ToolCallSource::Direct,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"command": "echo hi",
"cmd": "echo hi",
"login": false,
"timeout_ms": 1_000_u64,
"yield_time_ms": 10_000_u64,
"workdir": workdir,
})
.to_string(),
+3 -72
View File
@@ -18,7 +18,6 @@ use crate::session::turn_context::TurnContext;
use crate::tools::flat_tool_name;
use crate::tools::hook_names::HookToolName;
use crate::tools::runtimes::apply_patch::ApplyPatchApprovalKey;
use crate::tools::runtimes::shell::ApprovalKey;
use crate::tools::runtimes::unified_exec::UnifiedExecApprovalKey;
use crate::tools::sandboxing::ApprovalRequestReasons;
use crate::tools::sandboxing::PermissionRequestPayload;
@@ -65,17 +64,6 @@ pub(crate) struct ApprovalContext {
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum ApprovalAction {
Shell {
id: String,
environment_id: String,
command: Vec<String>,
hook_command: String,
cwd: PathUri,
sandbox_permissions: SandboxPermissions,
additional_permissions: Option<AdditionalPermissionProfile>,
justification: Option<String>,
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
},
ExecCommand {
id: String,
environment_id: String,
@@ -153,7 +141,6 @@ pub(crate) enum ApprovalAction {
#[derive(Clone, Debug, Eq, Hash, PartialEq, serde::Serialize)]
#[serde(untagged)]
pub(crate) enum ApprovalCacheKey {
Shell(ApprovalKey),
ExecCommand(UnifiedExecApprovalKey),
ApplyPatch(ApplyPatchApprovalKey),
}
@@ -161,12 +148,7 @@ pub(crate) enum ApprovalCacheKey {
impl ApprovalAction {
pub(crate) fn permission_request_payload(&self) -> PermissionRequestPayload {
match self {
Self::Shell {
hook_command,
justification,
..
}
| Self::ExecCommand {
Self::ExecCommand {
hook_command,
justification,
..
@@ -214,20 +196,6 @@ impl ApprovalAction {
pub(crate) fn cache_keys(&self) -> Vec<ApprovalCacheKey> {
match self {
Self::Shell {
environment_id,
command,
cwd,
sandbox_permissions,
additional_permissions,
..
} => vec![ApprovalCacheKey::Shell(ApprovalKey {
environment_id: environment_id.clone(),
command: canonicalize_command_for_approval(command),
cwd: cwd.clone(),
sandbox_permissions: *sandbox_permissions,
additional_permissions: additional_permissions.clone(),
})],
Self::ExecCommand {
environment_id,
command,
@@ -269,23 +237,6 @@ impl ApprovalAction {
fn into_guardian_request(self) -> std::io::Result<crate::guardian::GuardianApprovalRequest> {
Ok(match self {
Self::Shell {
id,
environment_id,
command,
cwd,
sandbox_permissions,
additional_permissions,
justification,
..
} => crate::guardian::GuardianApprovalRequest::Shell {
id,
command,
cwd: guardian_cwd(&environment_id, cwd)?,
sandbox_permissions,
additional_permissions,
justification,
},
Self::ExecCommand {
id,
environment_id,
@@ -663,16 +614,7 @@ impl Session {
ctx: &ApprovalContext,
) -> ReviewDecision {
match action {
ApprovalAction::Shell {
environment_id,
command,
cwd,
additional_permissions,
justification,
proposed_execpolicy_amendment,
..
}
| ApprovalAction::ExecCommand {
ApprovalAction::ExecCommand {
environment_id,
command,
cwd,
@@ -690,18 +632,7 @@ impl Session {
));
}
};
let tool_name = match action {
ApprovalAction::Shell { .. } => "shell",
ApprovalAction::ExecCommand { .. } => "unified_exec",
#[cfg(unix)]
ApprovalAction::Execve { .. } => unreachable!("matched command approval"),
ApprovalAction::ApplyPatch { .. } => unreachable!("matched command approval"),
ApprovalAction::McpToolCall { .. }
| ApprovalAction::NetworkAccess { .. }
| ApprovalAction::RequestPermissions { .. } => {
unreachable!("matched command approval")
}
};
let tool_name = "unified_exec";
let reason = ctx
.retry_reason
.clone()
+35 -6
View File
@@ -367,7 +367,7 @@ impl ToolOutput for ExecCommandToolOutput {
}
Some(JsonValue::String(
self.truncated_output(self.model_output_max_tokens()),
self.truncated_output_with_policy(self.model_output_policy()),
))
}
@@ -405,13 +405,21 @@ impl ToolOutput for ExecCommandToolOutput {
}
impl ExecCommandToolOutput {
fn model_output_max_tokens(&self) -> usize {
resolve_max_tokens(self.max_output_tokens).min(self.truncation_policy.token_budget())
fn model_output_policy(&self) -> TruncationPolicy {
let requested_policy = TruncationPolicy::Tokens(resolve_max_tokens(self.max_output_tokens));
if requested_policy.byte_budget() < self.truncation_policy.byte_budget() {
requested_policy
} else {
self.truncation_policy
}
}
pub(crate) fn truncated_output(&self, max_tokens: usize) -> String {
self.truncated_output_with_policy(TruncationPolicy::Tokens(max_tokens))
}
fn truncated_output_with_policy(&self, policy: TruncationPolicy) -> String {
let text = String::from_utf8_lossy(&self.raw_output).to_string();
let policy = TruncationPolicy::Tokens(max_tokens);
let Some(omitted_bytes) = self.output_omitted_bytes else {
return formatted_truncate_text(&text, policy);
};
@@ -462,9 +470,30 @@ impl ExecCommandToolOutput {
}
sections.push("Output:".to_string());
sections.push(self.truncated_output(self.model_output_max_tokens()));
let header = sections.join("\n");
let output_budget = (self.truncation_policy * 1.2)
.byte_budget()
.saturating_sub(header.len().saturating_add(/*rhs*/ 1));
let mut policy = self.model_output_policy();
let mut output = self.truncated_output_with_policy(policy);
sections.join("\n")
// History applies this same serialization budget to the complete response.
// Reserve room for metadata, warning headers, and the truncation marker so
// it does not truncate an already-truncated output a second time.
while output.len() > output_budget && policy.byte_budget() > 0 {
let excess_bytes = output.len() - output_budget;
policy = match policy {
TruncationPolicy::Bytes(bytes) => {
TruncationPolicy::Bytes(bytes.saturating_sub(excess_bytes))
}
TruncationPolicy::Tokens(tokens) => TruncationPolicy::Tokens(
tokens.saturating_sub(TruncationPolicy::Bytes(excess_bytes).token_budget()),
),
};
output = self.truncated_output_with_policy(policy);
}
format!("{header}\n{output}")
}
}
+46
View File
@@ -467,6 +467,52 @@ fn exec_command_tool_output_formats_truncated_response() {
}
}
#[test]
fn exec_command_tool_output_reserves_metadata_budget_and_preserves_policy_units() {
let payload = ToolPayload::Function {
arguments: "{}".to_string(),
};
let raw_output = (1..=150)
.map(|line| format!("{line}\n"))
.collect::<String>()
.into_bytes();
for (policy, marker) in [
(TruncationPolicy::Bytes(200), "chars truncated"),
(TruncationPolicy::Tokens(50), "tokens truncated"),
] {
let response = ExecCommandToolOutput {
event_call_id: "call-42".to_string(),
chunk_id: "abc123".to_string(),
wall_time: std::time::Duration::from_millis(/*millis*/ 1250),
raw_output: raw_output.clone(),
truncation_policy: policy,
max_output_tokens: None,
process_id: None,
exit_code: Some(0),
original_token_count: Some(123),
output_omitted_bytes: None,
hook_command: None,
}
.to_response_item("call-42", &payload);
let ResponseInputItem::FunctionCallOutput { output, .. } = response else {
panic!("expected FunctionCallOutput");
};
let text = output
.body
.to_text()
.expect("exec output should serialize as text");
assert!(text.len() <= (policy * 1.2).byte_budget());
assert_eq!(text.matches(marker).count(), 1);
assert!(text.contains("Original token count: 123"));
assert!(text.contains("Total output lines: 150"));
assert!(text.contains("\n1\n2\n3\n"));
assert!(text.ends_with("149\n150\n"));
}
}
#[test]
fn exec_command_tool_output_preserves_omission_metadata_when_truncated() {
let payload = ToolPayload::Function {
+1 -54
View File
@@ -26,7 +26,6 @@ use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::PatchApplyStatus;
use codex_protocol::protocol::TurnDiffEvent;
use codex_shell_command::parse_command::parse_command;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use codex_utils_string::truncate_middle_with_token_budget;
use std::collections::HashMap;
@@ -180,13 +179,6 @@ async fn emit_exec_command_begin(ctx: ToolEventCtx<'_>, exec_input: &ExecCommand
}
// Concrete, allocation-free emitter: avoid trait objects and boxed futures.
pub(crate) enum ToolEmitter {
Shell {
command: Vec<String>,
cwd: PathUri,
source: ExecCommandSource,
parsed_cmd: Vec<ParsedCommand>,
plugin_attribution: Option<PluginCommandAttribution>,
},
ApplyPatch {
changes: HashMap<PathBuf, FileChange>,
auto_approved: bool,
@@ -203,22 +195,6 @@ pub(crate) enum ToolEmitter {
}
impl ToolEmitter {
pub fn shell(
command: Vec<String>,
cwd: AbsolutePathBuf,
source: ExecCommandSource,
plugin_attribution: Option<PluginCommandAttribution>,
) -> Self {
let parsed_cmd = parse_command(&command);
Self::Shell {
command,
cwd: PathUri::from_abs_path(&cwd),
source,
parsed_cmd,
plugin_attribution,
}
}
pub fn apply_patch_for_environment(
changes: HashMap<PathBuf, FileChange>,
auto_approved: bool,
@@ -251,33 +227,6 @@ impl ToolEmitter {
pub async fn emit(&self, ctx: ToolEventCtx<'_>, stage: ToolEventStage<'_>) {
match (self, stage) {
(
Self::Shell {
command,
cwd,
source,
parsed_cmd,
plugin_attribution,
..
},
stage,
) => {
emit_exec_stage(
ctx,
ExecCommandInput::new(
command,
cwd,
parsed_cmd,
*source,
/*interaction_input*/ None,
/*process_id*/ None,
plugin_attribution.as_ref(),
),
stage,
)
.await;
}
(
Self::ApplyPatch {
changes,
@@ -491,9 +440,7 @@ impl ToolEmitter {
// TODO: We should add a new ToolError variant for user-declined approvals.
let normalized = if msg == "rejected by user" {
match self {
Self::Shell { .. } | Self::UnifiedExec { .. } => {
"exec command rejected by user".to_string()
}
Self::UnifiedExec { .. } => "exec command rejected by user".to_string(),
Self::ApplyPatch { .. } => "patch rejected by user".to_string(),
}
} else {
-15
View File
@@ -24,7 +24,6 @@ pub(crate) mod request_plugin_install_spec;
mod request_user_input;
pub(crate) mod request_user_input_spec;
mod send_user_message_async;
mod shell;
pub(crate) mod shell_spec;
mod sleep;
mod test_sync;
@@ -70,8 +69,6 @@ pub use request_permissions::RequestPermissionsHandler;
pub use request_plugin_install::RequestPluginInstallHandler;
pub use request_user_input::RequestUserInputHandler;
pub use send_user_message_async::SendUserMessageAsyncHandler;
pub use shell::ShellCommandHandler;
pub(crate) use shell::ShellCommandHandlerOptions;
pub use sleep::SleepHandler;
pub use test_sync::TestSyncHandler;
pub(crate) use tool_search::ToolSearchHandlerCache;
@@ -156,18 +153,6 @@ where
parse_arguments(arguments)
}
fn resolve_workdir_base_path(
arguments: &str,
default_cwd: &AbsolutePathBuf,
) -> Result<AbsolutePathBuf, FunctionCallError> {
let arguments: Value = parse_arguments(arguments)?;
Ok(arguments
.get("workdir")
.and_then(Value::as_str)
.filter(|workdir| !workdir.is_empty())
.map_or_else(|| default_cwd.clone(), |workdir| default_cwd.join(workdir)))
}
fn resolve_tool_environment<'a>(
environments: &'a TurnEnvironmentSnapshot,
environment_id: Option<&str>,
-252
View File
@@ -1,252 +0,0 @@
use codex_features::Feature;
use codex_protocol::models::ShellCommandToolCallParams;
use serde_json::Value as JsonValue;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use crate::exec::ExecParams;
use crate::exec_policy::ExecApprovalRequest;
use crate::function_tool::FunctionCallError;
use crate::session::step_context::StepContext;
use crate::session::turn_context::TurnEnvironment;
use crate::shell::ShellType;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx;
use crate::tools::handlers::apply_granted_turn_permissions;
use crate::tools::handlers::apply_patch::intercept_apply_patch;
use crate::tools::handlers::implicit_granted_permissions;
use crate::tools::handlers::normalize_and_validate_additional_permissions;
use crate::tools::handlers::parse_arguments;
use crate::tools::orchestrator::ToolOrchestrator;
use crate::tools::runtimes::shell::ShellRequest;
use crate::tools::runtimes::shell::ShellRuntime;
use crate::tools::runtimes::shell::ShellRuntimeBackend;
use crate::tools::sandboxing::ToolCtx;
use codex_core_plugins::strip_output_env;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::protocol::ExecCommandSource;
use codex_tools::ToolName;
use codex_utils_path_uri::PathUri;
mod shell_command;
pub use shell_command::ShellCommandHandler;
pub(crate) use shell_command::ShellCommandHandlerOptions;
fn shell_command_payload_command(payload: &ToolPayload) -> Option<String> {
let ToolPayload::Function { arguments } = payload else {
return None;
};
parse_arguments::<ShellCommandToolCallParams>(arguments)
.ok()
.map(|params| params.command)
}
struct RunExecLikeArgs {
tool_name: ToolName,
exec_params: ExecParams,
cancellation_token: CancellationToken,
hook_command: String,
shell_type: Option<ShellType>,
additional_permissions: Option<AdditionalPermissionProfile>,
prefix_rule: Option<Vec<String>>,
session: Arc<crate::session::session::Session>,
step_context: Arc<StepContext>,
turn_environment: TurnEnvironment,
tracker: crate::tools::context::SharedTurnDiffTracker,
call_id: String,
shell_runtime_backend: ShellRuntimeBackend,
}
async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, FunctionCallError> {
let RunExecLikeArgs {
tool_name,
exec_params,
cancellation_token,
hook_command,
shell_type,
additional_permissions,
prefix_rule,
session,
step_context,
turn_environment,
tracker,
call_id,
shell_runtime_backend,
} = args;
let turn = Arc::clone(&step_context.turn);
let fs = turn_environment.environment.get_filesystem();
let mut explicit_env_overrides = turn_environment.shell_environment_policy().r#set.clone();
let mut env = exec_params.env.clone();
strip_output_env(&mut env);
strip_output_env(&mut explicit_env_overrides);
let exec_permission_approvals_enabled =
session.features().enabled(Feature::ExecPermissionApprovals);
let requested_additional_permissions = additional_permissions.clone();
let effective_additional_permissions = apply_granted_turn_permissions(
session.as_ref(),
&turn_environment.selection.environment_id,
exec_params.cwd.as_path(),
exec_params.sandbox_permissions,
additional_permissions,
)
.await;
let additional_permissions_allowed = exec_permission_approvals_enabled
|| (session.features().enabled(Feature::RequestPermissionsTool)
&& effective_additional_permissions.permissions_preapproved);
let normalized_additional_permissions = implicit_granted_permissions(
exec_params.sandbox_permissions,
requested_additional_permissions.as_ref(),
&effective_additional_permissions,
)
.map_or_else(
|| {
normalize_and_validate_additional_permissions(
additional_permissions_allowed,
turn.approval_policy(),
effective_additional_permissions.sandbox_permissions,
effective_additional_permissions.additional_permissions,
effective_additional_permissions.permissions_preapproved,
&exec_params.cwd,
)
},
|permissions| Ok(Some(permissions)),
)
.map_err(FunctionCallError::RespondToModel)?;
// Approval policy guard for explicit escalation in non-OnRequest modes.
// Sticky turn permissions have already been approved, so they should
// continue through the normal exec approval flow for the command.
if effective_additional_permissions
.sandbox_permissions
.requests_sandbox_override()
&& !effective_additional_permissions.permissions_preapproved
&& !matches!(
turn.approval_policy(),
codex_protocol::protocol::AskForApproval::OnRequest
)
{
let approval_policy = turn.approval_policy();
return Err(FunctionCallError::RespondToModel(format!(
"approval policy is {approval_policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {approval_policy:?}"
)));
}
// Intercept apply_patch if present.
let apply_patch_cwd = PathUri::from_abs_path(&exec_params.cwd);
if let Some(output) = intercept_apply_patch(
&exec_params.command,
&apply_patch_cwd,
fs.as_ref(),
turn_environment.clone(),
session.clone(),
Arc::clone(&step_context),
Some(&tracker),
&call_id,
tool_name.name.as_str(),
)
.await?
{
return Ok(output);
}
let source = ExecCommandSource::Agent;
let plugin_attribution =
turn.plugin_attribution_for_command(&exec_params.command, &exec_params.cwd);
let emitter = ToolEmitter::shell(
exec_params.command.clone(),
exec_params.cwd.clone(),
source,
plugin_attribution,
);
let event_ctx = ToolEventCtx::new(
session.as_ref(),
turn.as_ref(),
&call_id,
/*turn_diff_tracker*/ None,
);
emitter.begin(event_ctx).await;
let exec_approval_requirement = session
.services
.exec_policy
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &exec_params.command,
approval_policy: turn.approval_policy(),
permission_profile: turn_environment.permission_profile().clone(),
environment_policy: turn_environment.config().exec_policy.as_ref(),
windows_sandbox_level: turn.windows_sandbox_level,
sandbox_permissions: if effective_additional_permissions.permissions_preapproved {
codex_protocol::models::SandboxPermissions::UseDefault
} else {
effective_additional_permissions.sandbox_permissions
},
prefix_rule,
allow_prefix_rules: turn.allow_prefix_rules(),
})
.await;
let req = ShellRequest {
command: exec_params.command.clone(),
turn_environment: turn_environment.clone(),
shell_type,
hook_command,
cwd: exec_params.cwd.clone(),
timeout_ms: exec_params.expiration.timeout_ms(),
cancellation_token,
env,
explicit_env_overrides,
network: exec_params.network.clone(),
sandbox_permissions: effective_additional_permissions.sandbox_permissions,
additional_permissions: normalized_additional_permissions,
#[cfg(unix)]
additional_permissions_preapproved: effective_additional_permissions
.permissions_preapproved,
justification: exec_params.justification.clone(),
exec_approval_requirement,
};
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = ShellRuntime::for_shell_command(shell_runtime_backend);
let tool_ctx = ToolCtx {
session: session.clone(),
step_context,
call_id: call_id.clone(),
tool_name,
};
let out = orchestrator
.run(&mut runtime, &req, &tool_ctx, &turn, turn.approval_policy())
.await
.map(|result| result.output);
let event_ctx = ToolEventCtx::new(
session.as_ref(),
turn.as_ref(),
&call_id,
/*turn_diff_tracker*/ None,
);
let post_tool_use_response = out
.as_ref()
.ok()
.map(|output| {
crate::tools::format_exec_output_str(output, turn.model_info.truncation_policy.into())
})
.map(JsonValue::String);
let content = emitter
.finish(event_ctx, out, /*applied_patch_delta*/ None)
.await?;
Ok(FunctionToolOutput {
body: vec![
codex_protocol::models::FunctionCallOutputContentItem::InputText { text: content },
],
success: Some(true),
post_tool_use_response,
})
}
#[cfg(test)]
#[path = "shell_tests.rs"]
mod tests;
@@ -1,306 +0,0 @@
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
use codex_protocol::models::ShellCommandToolCallParams;
use codex_tools::ShellCommandBackendConfig;
use codex_tools::ToolName;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecParams;
use crate::exec_env::create_env;
use crate::exec_env::inject_apply_patch_env;
use crate::exec_env::inject_permission_profile_env;
use crate::exec_env::inject_session_id_env;
use crate::function_tool::FunctionCallError;
use crate::maybe_emit_implicit_skill_invocation;
use crate::session::turn_context::TurnContext;
use crate::session::turn_context::TurnEnvironment;
use crate::shell::Shell;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::parse_arguments_with_base_path;
use crate::tools::handlers::resolve_sandbox_permissions;
use crate::tools::handlers::resolve_workdir_base_path;
use crate::tools::handlers::rewrite_function_string_argument;
use crate::tools::handlers::updated_hook_command;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::tools::registry::ToolExecutor;
use crate::tools::runtimes::shell::ShellRuntimeBackend;
use codex_tools::ToolSpec;
use super::super::shell_spec::CommandToolOptions;
use super::super::shell_spec::create_shell_command_tool;
use super::RunExecLikeArgs;
use super::run_exec_like;
use super::shell_command_payload_command;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ShellCommandBackend {
Classic,
ZshFork,
}
pub struct ShellCommandHandler {
backend: ShellCommandBackend,
options: ShellCommandHandlerOptions,
}
#[derive(Clone, Copy)]
pub(crate) struct ShellCommandHandlerOptions {
pub(crate) backend_config: ShellCommandBackendConfig,
pub(crate) allow_login_shell: bool,
pub(crate) exec_permission_approvals_enabled: bool,
}
impl ShellCommandHandler {
pub(crate) fn new(options: ShellCommandHandlerOptions) -> Self {
let backend = match options.backend_config {
ShellCommandBackendConfig::Classic => ShellCommandBackend::Classic,
ShellCommandBackendConfig::ZshFork => ShellCommandBackend::ZshFork,
};
Self { backend, options }
}
fn shell_runtime_backend(&self) -> ShellRuntimeBackend {
match self.backend {
ShellCommandBackend::Classic => ShellRuntimeBackend::ShellCommandClassic,
ShellCommandBackend::ZshFork => ShellRuntimeBackend::ShellCommandZshFork,
}
}
pub(super) fn resolve_use_login_shell(
login: Option<bool>,
allow_login_shell: bool,
) -> Result<bool, FunctionCallError> {
if !allow_login_shell && login == Some(true) {
return Err(FunctionCallError::RespondToModel(
"login shell is disabled by config; omit `login` or set it to false.".to_string(),
));
}
Ok(login.unwrap_or(allow_login_shell))
}
pub(super) fn base_command(shell: &Shell, command: &str, use_login_shell: bool) -> Vec<String> {
shell.derive_exec_args(command, use_login_shell)
}
pub(super) fn to_exec_params(
params: &ShellCommandToolCallParams,
session: &crate::session::session::Session,
turn_context: &TurnContext,
turn_environment: &TurnEnvironment,
cwd: AbsolutePathBuf,
) -> Result<ExecParams, FunctionCallError> {
let session_shell = session.user_shell();
let shell = turn_environment
.shell
.as_ref()
.unwrap_or(session_shell.as_ref());
let use_login_shell = Self::resolve_use_login_shell(
params.login,
turn_environment.config().allow_login_shell,
)?;
let command = Self::base_command(shell, &params.command, use_login_shell);
let mut env = create_env(
turn_environment.shell_environment_policy(),
Some(session.thread_id),
);
inject_session_id_env(&mut env, session.session_id());
inject_apply_patch_env(&mut env, &turn_context.config.features);
let active_permission_profile = turn_environment.active_permission_profile();
inject_permission_profile_env(&mut env, active_permission_profile.as_ref());
let sandbox_permissions = resolve_sandbox_permissions(
params.sandbox_permissions,
params.justification.as_deref(),
)?;
Ok(ExecParams {
command,
cwd,
expiration: params.timeout_ms.into(),
capture_policy: ExecCapturePolicy::ShellTool,
env,
network: turn_context.network.clone(),
network_environment_id: Some(turn_environment.selection.environment_id.clone()),
sandbox_permissions,
windows_sandbox_level: turn_context.windows_sandbox_level,
windows_sandbox_private_desktop: turn_context
.config
.permissions
.windows_sandbox_private_desktop,
justification: params.justification.clone(),
arg0: None,
})
}
}
impl From<ShellCommandBackendConfig> for ShellCommandHandler {
fn from(backend_config: ShellCommandBackendConfig) -> Self {
Self::new(ShellCommandHandlerOptions {
backend_config,
allow_login_shell: false,
exec_permission_approvals_enabled: false,
})
}
}
impl ToolExecutor<ToolInvocation> for ShellCommandHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain("shell_command")
}
fn spec(&self) -> ToolSpec {
create_shell_command_tool(CommandToolOptions {
allow_login_shell: self.options.allow_login_shell,
exec_permission_approvals_enabled: self.options.exec_permission_approvals_enabled,
})
}
fn supports_parallel_tool_calls(&self) -> bool {
true
}
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl ShellCommandHandler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
let ToolInvocation {
session,
turn,
step_context,
cancellation_token,
tracker,
call_id,
payload,
..
} = invocation;
let tool_name = self.tool_name();
let ToolPayload::Function { arguments } = payload else {
return Err(FunctionCallError::RespondToModel(format!(
"unsupported payload for shell_command handler: {tool_name}"
)));
};
let Some(turn_environment) = step_context.environments.primary().cloned() else {
return Err(FunctionCallError::RespondToModel(
"shell is unavailable in this session".to_string(),
));
};
let environment_cwd = turn_environment.cwd().to_abs_path().map_err(|err| {
FunctionCallError::RespondToModel(format!(
"shell_command cwd `{}` is not native to the Codex host: {err}",
turn_environment.cwd()
))
})?;
let cwd = resolve_workdir_base_path(&arguments, &environment_cwd)?;
let params: ShellCommandToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?;
maybe_emit_implicit_skill_invocation(
session.as_ref(),
turn.as_ref(),
&params.command,
&PathUri::from_abs_path(&cwd),
Some(&cwd),
LOCAL_ENVIRONMENT_ID,
)
.await;
let prefix_rule = params.prefix_rule.clone();
let exec_params = Self::to_exec_params(
&params,
session.as_ref(),
turn.as_ref(),
&turn_environment,
cwd,
)?;
let shell_type = Some(
turn_environment
.shell
.as_ref()
.map_or_else(|| session.user_shell().shell_type, |shell| shell.shell_type),
);
run_exec_like(RunExecLikeArgs {
tool_name,
exec_params,
cancellation_token,
hook_command: params.command,
shell_type,
additional_permissions: params.additional_permissions.clone(),
prefix_rule,
session,
step_context,
turn_environment,
tracker,
call_id,
shell_runtime_backend: self.shell_runtime_backend(),
})
.await
.map(boxed_tool_output)
}
}
impl CoreToolRuntime for ShellCommandHandler {
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(payload, ToolPayload::Function { .. })
}
fn waits_for_runtime_cancellation(&self) -> bool {
true
}
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
shell_command_payload_command(&invocation.payload).map(|command| PreToolUsePayload {
tool_name: HookToolName::bash(),
tool_input: serde_json::json!({ "command": command }),
})
}
fn with_updated_hook_input(
&self,
mut invocation: ToolInvocation,
updated_input: serde_json::Value,
) -> Result<ToolInvocation, FunctionCallError> {
let ToolPayload::Function { arguments } = invocation.payload else {
return Err(FunctionCallError::RespondToModel(
"hook input rewrite received unsupported shell_command payload".to_string(),
));
};
invocation.payload = ToolPayload::Function {
arguments: rewrite_function_string_argument(
&arguments,
"shell_command",
"command",
updated_hook_command(&updated_input)?,
)?,
};
Ok(invocation)
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
result: &dyn crate::tools::context::ToolOutput,
) -> Option<PostToolUsePayload> {
let tool_response =
result.post_tool_use_response(&invocation.call_id, &invocation.payload)?;
let command = shell_command_payload_command(&invocation.payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: invocation.call_id.clone(),
tool_input: serde_json::json!({ "command": command }),
tool_response,
})
}
}
@@ -154,76 +154,6 @@ pub fn create_write_stdin_tool() -> ToolSpec {
})
}
pub fn create_shell_command_tool(options: CommandToolOptions) -> ToolSpec {
let mut properties = BTreeMap::from([
(
"command".to_string(),
JsonSchema::string(Some(
"Shell script to run in the user's default shell.".to_string(),
)),
),
(
"workdir".to_string(),
JsonSchema::string(Some(
"Working directory for the command. Defaults to the turn cwd.".to_string(),
)),
),
(
"timeout_ms".to_string(),
JsonSchema::number(Some(
"Maximum command runtime. Defaults to 10000 ms.".to_string(),
)),
),
]);
if options.allow_login_shell {
properties.insert(
"login".to_string(),
JsonSchema::boolean(Some(
"True runs with login shell semantics; false disables them. Defaults to true."
.to_string(),
)),
);
}
properties.extend(create_approval_parameters(
options.exec_permission_approvals_enabled,
));
let description = if cfg!(windows) {
format!(
r#"Runs a Powershell command (Windows) and returns its output.
Examples of valid command strings:
- ls -a (show hidden): "Get-ChildItem -Force"
- recursive find by name: "Get-ChildItem -Recurse -Filter *.py"
- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"
- ps aux | grep python: "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}"
- setting an env var: "$env:FOO='bar'; echo $env:FOO"
- running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -"
{}"#,
windows_shell_guidance()
)
} else {
r#"Runs a shell command and returns its output.
- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."#
.to_string()
};
ToolSpec::Function(ResponsesApiTool {
name: "shell_command".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
properties,
Some(vec!["command".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}
pub fn create_request_permissions_tool(description: String) -> ToolSpec {
let properties = BTreeMap::from([
(
@@ -201,77 +201,3 @@ fn request_permissions_tool_includes_full_permission_schema() {
})
);
}
#[test]
fn shell_command_tool_matches_expected_spec() {
let tool = create_shell_command_tool(CommandToolOptions {
allow_login_shell: true,
exec_permission_approvals_enabled: false,
});
let description = if cfg!(windows) {
r#"Runs a Powershell command (Windows) and returns its output.
Examples of valid command strings:
- ls -a (show hidden): "Get-ChildItem -Force"
- recursive find by name: "Get-ChildItem -Recurse -Filter *.py"
- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"
- ps aux | grep python: "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"
- setting an env var: "$env:FOO='bar'; echo $env:FOO"
- running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -""#
.to_string()
+ &windows_shell_guidance_description()
} else {
r#"Runs a shell command and returns its output.
- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."#
.to_string()
};
let mut properties = BTreeMap::from([
(
"command".to_string(),
JsonSchema::string(Some(
"Shell script to run in the user's default shell.".to_string(),
)),
),
(
"workdir".to_string(),
JsonSchema::string(Some(
"Working directory for the command. Defaults to the turn cwd.".to_string(),
)),
),
(
"timeout_ms".to_string(),
JsonSchema::number(Some(
"Maximum command runtime. Defaults to 10000 ms.".to_string(),
)),
),
(
"login".to_string(),
JsonSchema::boolean(Some(
"True runs with login shell semantics; false disables them. Defaults to true."
.to_string(),
)),
),
]);
properties.extend(create_approval_parameters(
/*exec_permission_approvals_enabled*/ false,
));
assert_eq!(
tool,
ToolSpec::Function(ResponsesApiTool {
name: "shell_command".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
properties,
Some(vec!["command".to_string()]),
Some(false.into())
),
output_schema: None,
})
);
}
@@ -1,336 +0,0 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use codex_protocol::config_types::EnvironmentVariablePattern;
use codex_protocol::config_types::ShellEnvironmentPolicy;
use codex_protocol::config_types::ShellEnvironmentPolicyInherit;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::ShellCommandToolCallParams;
use pretty_assertions::assert_eq;
use crate::config::PermissionProfileSnapshot;
use crate::environment_selection::EnvironmentConfigOrigin;
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::exec_env::inject_session_id_env;
use crate::sandboxing::SandboxPermissions;
use crate::session::step_context::StepContext;
use crate::session::tests::make_session_and_context;
use crate::session::turn_context::TurnEnvironment;
use crate::shell::Shell;
use crate::shell::ShellType;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolCallSource;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::ShellCommandHandler;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::CoreToolRuntime;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_protocol::protocol::EnvironmentConfig;
use codex_protocol::protocol::EnvironmentConfigState;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_utils_path_uri::PathUri;
use serde_json::json;
use tokio::sync::Mutex;
#[tokio::test]
async fn shell_command_handler_to_exec_params_uses_selected_environment() {
let (session, mut turn_context) = make_session_and_context().await;
let permission_profile = turn_context.config.permissions.permission_profile().clone();
let config = Arc::make_mut(&mut turn_context.config);
config.permissions.shell_environment_policy.r#set = HashMap::from([
("KEEP".to_string(), "from-thread".to_string()),
("DROP".to_string(), "from-thread".to_string()),
]);
config.permissions.shell_environment_policy.include_only =
vec![EnvironmentVariablePattern::new_case_insensitive("DROP")];
config
.permissions
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active(
permission_profile.clone(),
ActivePermissionProfile::new("thread-profile"),
))
.expect("set active permission profile");
let command = "echo hello".to_string();
let workdir = Some("subdir".to_string());
let login = None;
let timeout_ms = Some(1234);
let sandbox_permissions = SandboxPermissions::RequireEscalated;
let justification = Some("because tests".to_string());
let selected_shell = Shell {
shell_type: ShellType::Bash,
shell_path: PathBuf::from("/selected/bin/bash"),
};
let expected_command = selected_shell.derive_exec_args(&command, /*use_login_shell*/ true);
let selected_cwd = turn_context.config.cwd.join("selected-environment");
let expected_cwd = selected_cwd.join("subdir");
let active_permission_profile = ActivePermissionProfile::new("selected-profile");
let selected_shell_environment_policy = ShellEnvironmentPolicy {
inherit: ShellEnvironmentPolicyInherit::None,
include_only: vec![EnvironmentVariablePattern::new_case_insensitive("KEEP")],
r#set: turn_context
.config
.permissions
.shell_environment_policy
.r#set
.clone(),
..Default::default()
};
let selected_environment = TurnEnvironment::new(
TurnEnvironmentSelection {
environment_id: "selected-environment".to_string(),
cwd: PathUri::from_abs_path(&selected_cwd),
workspace_roots: Vec::new(),
config: EnvironmentConfigState::Ready(EnvironmentConfig {
allow_login_shell: true,
permission_profile: PermissionProfileSnapshot::active(
permission_profile,
active_permission_profile.clone(),
),
shell_environment_policy: selected_shell_environment_policy.clone(),
exec_policy: None,
mcp_policy: None,
network_policy: None,
selected_capability_roots: Vec::new(),
}),
},
EnvironmentConfigOrigin::Thread,
Arc::clone(
&turn_context
.environments
.primary()
.expect("primary environment")
.environment,
),
Some(selected_shell),
);
let mut expected_env = create_env(&selected_shell_environment_policy, Some(session.thread_id));
inject_session_id_env(&mut expected_env, session.session_id());
inject_permission_profile_env(&mut expected_env, Some(&active_permission_profile));
let params = ShellCommandToolCallParams {
command,
workdir,
login,
timeout_ms,
sandbox_permissions: Some(sandbox_permissions),
additional_permissions: None,
prefix_rule: None,
justification: justification.clone(),
};
let exec_params = ShellCommandHandler::to_exec_params(
&params,
&session,
&turn_context,
&selected_environment,
expected_cwd.clone(),
)
.expect("login shells should be allowed");
// ExecParams cannot derive Eq due to the CancellationToken field, so we manually compare the fields.
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),
Some(&active_permission_profile.id)
);
assert_eq!(exec_params.network, turn_context.network);
assert_eq!(
exec_params.network_environment_id.as_deref(),
Some("selected-environment")
);
assert_eq!(exec_params.expiration.timeout_ms(), timeout_ms);
assert_eq!(exec_params.sandbox_permissions, sandbox_permissions);
assert_eq!(exec_params.justification, justification);
assert_eq!(exec_params.arg0, None);
}
#[test]
fn shell_command_handler_respects_explicit_login_flag() {
let shell = Shell {
shell_type: ShellType::Bash,
shell_path: PathBuf::from("/bin/bash"),
};
let login_command = ShellCommandHandler::base_command(
&shell,
"echo login shell",
/*use_login_shell*/ true,
);
assert_eq!(
login_command,
shell.derive_exec_args("echo login shell", /*use_login_shell*/ true)
);
let non_login_command = ShellCommandHandler::base_command(
&shell,
"echo non login shell",
/*use_login_shell*/ false,
);
assert_eq!(
non_login_command,
shell.derive_exec_args("echo non login shell", /*use_login_shell*/ false)
);
}
#[tokio::test]
async fn shell_command_handler_defaults_to_non_login_when_disallowed() {
let (session, turn_context) = make_session_and_context().await;
let mut turn_environment = turn_context
.environments
.primary()
.expect("primary environment")
.clone();
turn_environment.config_mut().allow_login_shell = false;
let cwd = turn_environment
.cwd()
.to_abs_path()
.expect("native environment cwd");
let params = ShellCommandToolCallParams {
command: "echo hello".to_string(),
workdir: None,
login: None,
timeout_ms: None,
sandbox_permissions: None,
additional_permissions: None,
prefix_rule: None,
justification: None,
};
let exec_params = ShellCommandHandler::to_exec_params(
&params,
&session,
&turn_context,
&turn_environment,
cwd,
)
.expect("non-login shells should still be allowed");
assert_eq!(
exec_params.command,
session
.user_shell()
.derive_exec_args("echo hello", /*use_login_shell*/ false)
);
}
#[tokio::test]
async fn shell_command_handler_rejects_justification_without_sandbox_permissions() {
let (session, turn_context) = make_session_and_context().await;
let turn_environment = turn_context
.environments
.primary()
.expect("primary environment");
let cwd = turn_environment
.cwd()
.to_abs_path()
.expect("native environment cwd");
let params = ShellCommandToolCallParams {
command: "echo hello".to_string(),
workdir: None,
login: None,
timeout_ms: None,
sandbox_permissions: None,
additional_permissions: None,
prefix_rule: None,
justification: Some("Allow this command".to_string()),
};
let err = ShellCommandHandler::to_exec_params(
&params,
&session,
&turn_context,
turn_environment,
cwd,
)
.expect_err("justification without sandbox permissions should be rejected");
assert!(
err.to_string()
.contains("`justification` requires an explicit `sandbox_permissions`"),
"unexpected error: {err}"
);
}
#[test]
fn shell_command_handler_rejects_login_when_disallowed() {
let err =
ShellCommandHandler::resolve_use_login_shell(Some(true), /*allow_login_shell*/ false)
.expect_err("explicit login should be rejected");
assert!(
err.to_string()
.contains("login shell is disabled by config"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn shell_command_pre_tool_use_payload_uses_raw_command() {
let payload = ToolPayload::Function {
arguments: json!({ "command": "printf shell command" }).to_string(),
};
let (session, turn) = make_session_and_context().await;
let turn = Arc::new(turn);
let handler = ShellCommandHandler::from(codex_tools::ShellCommandBackendConfig::Classic);
assert_eq!(
handler.pre_tool_use_payload(&ToolInvocation {
session: session.into(),
step_context: StepContext::for_test(Arc::clone(&turn)),
turn,
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
call_id: "call-42".to_string(),
tool_name: codex_tools::ToolName::plain("shell_command"),
source: crate::tools::context::ToolCallSource::Direct,
payload,
}),
Some(crate::tools::registry::PreToolUsePayload {
tool_name: HookToolName::bash(),
tool_input: json!({ "command": "printf shell command" }),
})
);
}
#[tokio::test]
async fn build_post_tool_use_payload_uses_tool_output_wire_value() {
let payload = ToolPayload::Function {
arguments: json!({ "command": "printf shell command" }).to_string(),
};
let output = FunctionToolOutput {
body: vec![],
success: Some(true),
post_tool_use_response: Some(json!("shell output")),
};
let handler = ShellCommandHandler::from(codex_tools::ShellCommandBackendConfig::Classic);
let (session, turn) = make_session_and_context().await;
let turn = Arc::new(turn);
let invocation = ToolInvocation {
session: session.into(),
step_context: StepContext::for_test(Arc::clone(&turn)),
turn,
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
call_id: "call-42".to_string(),
tool_name: codex_tools::ToolName::plain("shell_command"),
source: ToolCallSource::Direct,
payload,
};
assert_eq!(
handler.post_tool_use_payload(&invocation, &output),
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "call-42".to_string(),
tool_input: json!({ "command": "printf shell command" }),
tool_response: json!("shell output"),
})
);
}
+6 -53
View File
@@ -45,16 +45,9 @@ use uuid::Uuid;
const ABANDONED_NETWORK_APPROVAL_MESSAGE: &str =
"network approval was cancelled before a decision was returned";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum NetworkApprovalMode {
Immediate,
Deferred,
}
#[derive(Clone, Debug)]
pub(crate) struct NetworkApprovalSpec {
pub network: Option<NetworkProxy>,
pub mode: NetworkApprovalMode,
pub trigger: GuardianNetworkAccessTrigger,
pub command: String,
pub environment_id: String,
@@ -97,16 +90,11 @@ impl DeferredNetworkApproval {
#[derive(Debug)]
pub(crate) struct ActiveNetworkApproval {
registration_id: Option<String>,
mode: NetworkApprovalMode,
cancellation_token: CancellationToken,
execution_proxy: NetworkProxy,
}
impl ActiveNetworkApproval {
pub(crate) fn mode(&self) -> NetworkApprovalMode {
self.mode
}
pub(crate) fn cancellation_token(&self) -> CancellationToken {
self.cancellation_token.clone()
}
@@ -118,21 +106,15 @@ impl ActiveNetworkApproval {
pub(crate) fn into_deferred(self) -> Option<DeferredNetworkApproval> {
let ActiveNetworkApproval {
registration_id,
mode,
cancellation_token,
execution_proxy,
} = self;
match (mode, registration_id) {
(NetworkApprovalMode::Deferred, Some(registration_id)) => {
Some(DeferredNetworkApproval {
registration_id,
cancellation_token,
finish_outcome: Arc::new(OnceCell::new()),
_execution_proxy: Some(execution_proxy),
})
}
_ => None,
}
registration_id.map(|registration_id| DeferredNetworkApproval {
registration_id,
cancellation_token,
finish_outcome: Arc::new(OnceCell::new()),
_execution_proxy: Some(execution_proxy),
})
}
}
@@ -576,18 +558,6 @@ impl NetworkApprovalService {
self.remove_call(registration_id).await
}
async fn finish_call(
&self,
registration_id: &str,
cancellation_token: &CancellationToken,
) -> Result<(), ToolError> {
let outcome = self
.finish_call_outcome(registration_id)
.await
.or_else(|| abandoned_network_approval_outcome(cancellation_token));
network_approval_outcome_to_result(outcome)
}
pub(crate) async fn record_blocked_request(&self, blocked: BlockedRequest) {
let Some(message) = denied_network_policy_message(&blocked) else {
return;
@@ -1055,7 +1025,6 @@ pub(crate) async fn begin_network_approval(
) -> Result<Option<ActiveNetworkApproval>, ToolError> {
let NetworkApprovalSpec {
network,
mode,
trigger,
command,
environment_id,
@@ -1097,27 +1066,11 @@ pub(crate) async fn begin_network_approval(
Ok(Some(ActiveNetworkApproval {
registration_id: Some(registration_id),
mode,
cancellation_token,
execution_proxy,
}))
}
pub(crate) async fn finish_immediate_network_approval(
session: &Session,
active: ActiveNetworkApproval,
) -> Result<(), ToolError> {
let Some(registration_id) = active.registration_id.as_deref() else {
return Ok(());
};
session
.services
.network_approval
.finish_call(registration_id, &active.cancellation_token)
.await
}
pub(crate) async fn finish_deferred_network_approval(
session: &Session,
deferred: Option<DeferredNetworkApproval>,
@@ -480,7 +480,7 @@ async fn register_call_with_default_shell_trigger(
turn_id: "turn-1".to_string(),
trigger: GuardianNetworkAccessTrigger {
call_id: "call-1".to_string(),
tool_name: "shell_command".to_string(),
tool_name: "exec_command".to_string(),
command: vec!["curl".to_string(), "https://example.com".to_string()],
cwd: PathUri::from_abs_path(&test_path_buf("/tmp").abs()),
sandbox_permissions: SandboxPermissions::UseDefault,
@@ -502,7 +502,7 @@ async fn active_call_preserves_triggering_command_context() {
let service = NetworkApprovalService::default();
let expected = GuardianNetworkAccessTrigger {
call_id: "call-1".to_string(),
tool_name: "shell_command".to_string(),
tool_name: "exec_command".to_string(),
command: vec!["curl".to_string(), "https://example.com".to_string()],
cwd: PathUri::parse("file:///C:/repo").expect("valid Windows path URI"),
sandbox_permissions: SandboxPermissions::UseDefault,
@@ -669,42 +669,6 @@ fn approval_denial_messages_are_bounded_for_model_context() {
assert!(message.contains("tokens truncated"));
}
#[tokio::test]
async fn finish_call_returns_denial_and_unregisters_active_call() {
let service = NetworkApprovalService::default();
let cancellation_token =
register_call_with_default_shell_trigger(&service, "registration-1").await;
service.record_call_outcome("registration-1", "network denied".to_string());
let err = service
.finish_call("registration-1", &cancellation_token)
.await
.expect_err("denial should be returned");
assert!(matches!(err, ToolError::Rejected(message) if message == "network denied"));
assert!(service.resolve_single_active_call().await.is_none());
assert_eq!(service.take_call_outcome("registration-1").await, None);
}
#[tokio::test]
async fn finish_call_reports_abandoned_network_approval() {
let service = NetworkApprovalService::default();
let cancellation_token =
register_call_with_default_shell_trigger(&service, "registration-1").await;
cancellation_token.cancel();
let err = service
.finish_call("registration-1", &cancellation_token)
.await
.expect_err("abandoned approval should be returned");
assert!(matches!(
err,
ToolError::Rejected(message) if message == ABANDONED_NETWORK_APPROVAL_MESSAGE
));
}
#[tokio::test]
async fn deferred_finish_reuses_denial_result_after_first_consumer() {
let service = NetworkApprovalService::default();
+8 -22
View File
@@ -12,10 +12,8 @@ use crate::tools::approvals::ApprovalContext;
use crate::tools::flat_tool_name;
use crate::tools::network_approval::ActiveNetworkApproval;
use crate::tools::network_approval::DeferredNetworkApproval;
use crate::tools::network_approval::NetworkApprovalMode;
use crate::tools::network_approval::begin_network_approval;
use crate::tools::network_approval::finish_deferred_network_approval;
use crate::tools::network_approval::finish_immediate_network_approval;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::SandboxOverride;
@@ -108,28 +106,16 @@ impl ToolOrchestrator {
return (run_result, None);
};
match network_approval.mode() {
NetworkApprovalMode::Immediate => {
let finalize_result =
finish_immediate_network_approval(&tool_ctx.session, network_approval).await;
if let Err(err) = finalize_result {
return (Err(err), None);
}
(run_result, None)
}
NetworkApprovalMode::Deferred => {
let deferred = network_approval.into_deferred();
if run_result.is_err() {
let finalize_result =
finish_deferred_network_approval(&tool_ctx.session, deferred).await;
if let Err(err) = finalize_result {
return (Err(err), None);
}
return (run_result, None);
}
(run_result, deferred)
let deferred = network_approval.into_deferred();
if run_result.is_err() {
let finalize_result =
finish_deferred_network_approval(&tool_ctx.session, deferred).await;
if let Err(err) = finalize_result {
return (Err(err), None);
}
return (run_result, None);
}
(run_result, deferred)
}
pub async fn run<Rq, Out, T>(
+1 -1
View File
@@ -269,7 +269,7 @@ impl ToolCallRuntime {
if call.tool_name.is_default_namespace()
&& matches!(
call.tool_name.name.as_str(),
"shell_command" | "unified_exec"
"exec_command" | "unified_exec"
)
{
format!("Wall time: {secs:.1} seconds\naborted by user")
+3 -1
View File
@@ -350,7 +350,9 @@ impl ToolRegistry {
exposure: ToolExposure,
) -> bool {
let tool_name = runtime.tool_name().with_default_namespace();
if tool_name.is_default_namespace() && tool_name.name == "shell_command" {
if tool_name.is_default_namespace()
&& matches!(tool_name.name.as_str(), "exec_command" | "shell_command")
{
tracing::warn!(tool_name = %tool_name, "skipping external tool with reserved name");
if self.tools.contains_key(&tool_name) {
self.record_collision(tool_name);
+28 -26
View File
@@ -296,39 +296,41 @@ fn registry_preserves_external_winners_and_trusted_synthetic_order() {
}
#[test]
fn reserved_shell_command_rejects_external_runtimes_without_a_builtin() {
fn reserved_command_tools_reject_external_runtimes_without_a_builtin() {
let handler = |tool_name| Arc::new(TestHandler { tool_name }) as Arc<dyn CoreToolRuntime>;
let shell_command_name = codex_tools::ToolName::plain("shell_command");
let namespaced_shell_command_name =
codex_tools::ToolName::namespaced("client", "shell_command");
let mut registry = ToolRegistry::default();
assert!(!registry.register_external(handler(shell_command_name.clone())));
assert!(!registry.register_external_with_exposure(
handler(shell_command_name.clone()),
ToolExposure::Direct,
));
assert!(
!registry.register_external(handler(codex_tools::ToolName::namespaced(
DEFAULT_FUNCTION_NAMESPACE,
"shell_command",
)))
);
assert!(registry.tool(&shell_command_name).is_none());
assert_eq!(registry.first_collision(), None);
for reserved_name in ["exec_command", "shell_command"] {
let tool_name = codex_tools::ToolName::plain(reserved_name);
let namespaced_tool_name = codex_tools::ToolName::namespaced("client", reserved_name);
let namespaced_handler = handler(namespaced_shell_command_name.clone());
assert!(registry.register_external(Arc::clone(&namespaced_handler)));
assert!(
registry
.tool(&namespaced_shell_command_name)
.is_some_and(|runtime| Arc::ptr_eq(&runtime, &namespaced_handler))
);
assert!(!registry.register_external(handler(tool_name.clone())));
assert!(
!registry
.register_external_with_exposure(handler(tool_name.clone()), ToolExposure::Direct)
);
assert!(
!registry.register_external(handler(codex_tools::ToolName::namespaced(
DEFAULT_FUNCTION_NAMESPACE,
reserved_name,
)))
);
assert!(registry.tool(&tool_name).is_none());
assert_eq!(registry.first_collision(), None);
let namespaced_handler = handler(namespaced_tool_name.clone());
assert!(registry.register_external(Arc::clone(&namespaced_handler)));
assert!(
registry
.tool(&namespaced_tool_name)
.is_some_and(|runtime| Arc::ptr_eq(&runtime, &namespaced_handler))
);
}
}
#[test]
fn registry_records_reserved_shell_command_when_a_matching_tool_exists() {
let tool_name = codex_tools::ToolName::plain("shell_command");
fn registry_records_reserved_exec_command_when_a_matching_tool_exists() {
let tool_name = codex_tools::ToolName::plain("exec_command");
let trusted = Arc::new(TestHandler {
tool_name: tool_name.clone(),
}) as Arc<dyn CoreToolRuntime>;
+1 -1
View File
@@ -167,7 +167,7 @@ async fn parallel_support_does_not_match_namespaced_local_tool_names() -> anyhow
&turn.dynamic_tools,
);
let parallel_tool_name = ["exec_command", "shell_command"]
let parallel_tool_name = ["exec_command"]
.into_iter()
.find(|name| {
router.tool_supports_parallel(&ToolCall {
-37
View File
@@ -10,7 +10,6 @@ use crate::exec_env::CODEX_THREAD_ID_ENV_VAR;
use crate::sandboxing::SandboxPermissions;
use crate::shell::Shell;
use crate::shell::ShellType;
use crate::tools::sandboxing::ToolError;
use codex_apply_patch::CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS_ENV_VAR;
use codex_core_plugins::PLUGIN_METRICS_OUTPUT_ENV_VAR;
#[cfg(unix)]
@@ -25,11 +24,8 @@ use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY;
pub(crate) use codex_network_proxy::is_managed_proxy_env_var;
pub(crate) use codex_network_proxy::strip_managed_proxy_env;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::shell_environment::is_non_inheritable_env_var;
use codex_sandboxing::SandboxCommand;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use std::collections::HashMap;
#[cfg(unix)]
use std::path::Path;
@@ -38,28 +34,6 @@ pub(crate) mod apply_patch;
pub(crate) mod shell;
pub(crate) mod unified_exec;
/// Shared helper to construct sandbox transform inputs from a tokenized command line and native
/// working directory. Validates that at least a program is present.
pub(crate) fn build_sandbox_command(
command: &[String],
cwd: &AbsolutePathBuf,
env: &HashMap<String, String>,
additional_permissions: Option<AdditionalPermissionProfile>,
) -> Result<SandboxCommand, ToolError> {
let (program, args) = command
.split_first()
.ok_or_else(|| ToolError::Rejected("command args are empty".to_string()))?;
let cwd = PathUri::from_abs_path(cwd);
Ok(SandboxCommand {
program: program.clone().into(),
args: args.to_vec(),
cwd,
env: env.clone(),
managed_network: None,
additional_permissions,
})
}
pub(crate) fn exec_env_for_sandbox_permissions(
env: &HashMap<String, String>,
sandbox_permissions: SandboxPermissions,
@@ -156,17 +130,6 @@ pub(crate) fn apply_package_path_prepend(
runtime_path_prepends.prepend(env, path_dir.as_path());
}
#[cfg(unix)]
pub(crate) fn prepend_zsh_fork_bin_to_path(
env: &mut HashMap<String, String>,
shell_zsh_path: &Path,
) -> Option<String> {
let zsh_bin_dir = shell_zsh_path
.parent()
.map(|path| path.to_string_lossy().to_string())?;
prepend_path_entry(env, &zsh_bin_dir)
}
#[cfg(unix)]
pub(crate) fn apply_zsh_fork_path_prepend(
env: &mut HashMap<String, String>,
+8 -26
View File
@@ -88,14 +88,14 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow::
let mut env = HashMap::from([("CUSTOM_ENV".to_string(), "kept".to_string())]);
proxy.apply_to_env(&mut env);
let command = vec!["/bin/echo".to_string(), "ok".to_string()];
let command = build_sandbox_command(
&command,
&command_cwd,
&exec_env_for_sandbox_permissions(&env, SandboxPermissions::RequireEscalated),
/*additional_permissions*/ None,
)
.expect("build sandbox command");
let command = codex_sandboxing::SandboxCommand {
program: "/bin/echo".into(),
args: vec!["ok".to_string()],
cwd: PathUri::from_abs_path(&command_cwd),
env: exec_env_for_sandbox_permissions(&env, SandboxPermissions::RequireEscalated),
managed_network: None,
additional_permissions: None,
};
assert_eq!(command.cwd, PathUri::from_abs_path(&command_cwd));
let sandbox_policy_cwd = PathUri::from_abs_path(&native_sandbox_policy_cwd);
let options = ExecOptions {
@@ -236,24 +236,6 @@ fn runtime_path_prepends_ignores_empty_path_entry() {
);
}
#[cfg(unix)]
#[test]
fn prepend_zsh_fork_bin_to_path_ignores_empty_parent() {
let mut env = HashMap::from([("PATH".to_string(), "/usr/bin:/bin".to_string())]);
let result = prepend_zsh_fork_bin_to_path(&mut env, PathBuf::from("zsh").as_path());
assert_eq!(
result, None,
"zsh fork helper should not report a PATH update for an empty parent"
);
assert_eq!(
env.get("PATH").map(String::as_str),
Some("/usr/bin:/bin"),
"zsh fork helper should leave PATH unchanged when the parent is empty"
);
}
#[cfg(unix)]
#[test]
fn apply_zsh_fork_path_prepend_uses_shell_parent() {
-331
View File
@@ -1,331 +0,0 @@
/*
Runtime: shell
Executes shell requests under the orchestrator: asks for approval when needed,
builds sandbox transform inputs, and runs them under the current SandboxAttempt.
*/
#[cfg(unix)]
pub(crate) mod unix_escalation;
pub(crate) mod zsh_fork_backend;
use crate::exec::ExecCapturePolicy;
use crate::guardian::GuardianNetworkAccessTrigger;
use crate::plugins::metrics::finish_and_track_measurements;
use crate::plugins::metrics::sidecar_for_command;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::SandboxPermissions;
use crate::sandboxing::execute_env;
use crate::session::turn_context::TurnEnvironment;
use crate::shell::ShellType;
use crate::tools::flat_tool_name;
use crate::tools::network_approval::NetworkApprovalMode;
use crate::tools::network_approval::NetworkApprovalSpec;
use crate::tools::runtimes::RuntimePathPrepends;
#[cfg(unix)]
use crate::tools::runtimes::apply_zsh_fork_path_prepend;
use crate::tools::runtimes::build_sandbox_command;
use crate::tools::runtimes::disable_powershell_profile_for_elevated_windows_sandbox;
use crate::tools::runtimes::exec_env_for_sandbox_permissions;
use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalAction;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::Sandboxable;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::managed_network_for_sandbox_permissions;
use crate::tools::sandboxing::sandbox_permissions_preserving_denied_reads;
use codex_core_plugins::PluginMetricsSidecar;
use codex_network_proxy::NetworkProxy;
use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_sandboxing::SandboxablePreference;
use codex_sandboxing::policy_transforms::merge_permission_profiles;
use codex_shell_command::powershell::prefix_powershell_script_with_utf8;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use std::collections::HashMap;
use tokio_util::sync::CancellationToken;
#[derive(Clone, Debug)]
pub struct ShellRequest {
pub command: Vec<String>,
pub turn_environment: TurnEnvironment,
pub shell_type: Option<ShellType>,
pub hook_command: String,
pub cwd: AbsolutePathBuf,
pub timeout_ms: Option<u64>,
pub cancellation_token: CancellationToken,
pub env: HashMap<String, String>,
pub explicit_env_overrides: HashMap<String, String>,
pub network: Option<NetworkProxy>,
pub sandbox_permissions: SandboxPermissions,
pub additional_permissions: Option<AdditionalPermissionProfile>,
#[cfg(unix)]
pub additional_permissions_preapproved: bool,
pub justification: Option<String>,
pub exec_approval_requirement: ExecApprovalRequirement,
}
/// Selects `ShellRuntime` behavior for different callers.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ShellRuntimeBackend {
/// Legacy backend for the `shell_command` tool.
///
/// Keeps `shell_command` on the standard shell runtime flow without the
/// zsh-fork shell-escalation adapter.
ShellCommandClassic,
/// zsh-fork backend for the `shell_command` tool.
///
/// On Unix, attempts to run via the zsh-fork + `codex-shell-escalation`
/// adapter, with fallback to the standard shell runtime flow if
/// prerequisites are not met.
ShellCommandZshFork,
}
pub struct ShellRuntime {
backend: ShellRuntimeBackend,
}
#[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)]
pub(crate) struct ApprovalKey {
pub(crate) environment_id: String,
pub(crate) command: Vec<String>,
pub(crate) cwd: PathUri,
pub(crate) sandbox_permissions: SandboxPermissions,
pub(crate) additional_permissions: Option<AdditionalPermissionProfile>,
}
impl ShellRuntime {
pub(crate) fn for_shell_command(backend: ShellRuntimeBackend) -> Self {
Self { backend }
}
fn stdout_stream(ctx: &ToolCtx) -> Option<crate::exec::StdoutStream> {
Some(crate::exec::StdoutStream {
sub_id: ctx.step_context.turn.sub_id.clone(),
call_id: ctx.call_id.clone(),
tx_event: ctx.session.get_tx_event(),
})
}
}
impl Sandboxable for ShellRuntime {
fn sandbox_preference(&self) -> SandboxablePreference {
SandboxablePreference::Auto
}
fn escalate_on_failure(&self) -> bool {
true
}
}
impl Approvable<ShellRequest> for ShellRuntime {
fn approval_action(
&self,
req: &ShellRequest,
call_id: &str,
) -> std::io::Result<ApprovalAction> {
Ok(ApprovalAction::Shell {
id: call_id.to_string(),
environment_id: req.turn_environment.selection.environment_id.clone(),
command: req.command.clone(),
hook_command: req.hook_command.clone(),
cwd: PathUri::from_abs_path(&req.cwd),
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
justification: req.justification.clone(),
proposed_execpolicy_amendment: req
.exec_approval_requirement
.proposed_execpolicy_amendment()
.cloned(),
})
}
fn exec_approval_requirement(&self, req: &ShellRequest) -> Option<ExecApprovalRequirement> {
Some(req.exec_approval_requirement.clone())
}
fn sandbox_permissions(&self, req: &ShellRequest) -> SandboxPermissions {
req.sandbox_permissions
}
}
impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
fn turn_environment<'a>(&self, req: &'a ShellRequest) -> &'a TurnEnvironment {
&req.turn_environment
}
fn network_approval_spec(
&self,
req: &ShellRequest,
ctx: &ToolCtx,
) -> Option<NetworkApprovalSpec> {
let file_system_sandbox_policy = req
.turn_environment
.permission_profile()
.file_system_sandbox_policy();
let sandbox_permissions = sandbox_permissions_preserving_denied_reads(
req.sandbox_permissions,
&file_system_sandbox_policy,
);
let network =
managed_network_for_sandbox_permissions(req.network.as_ref(), sandbox_permissions)?;
Some(NetworkApprovalSpec {
network: Some(network.clone()),
mode: NetworkApprovalMode::Immediate,
trigger: GuardianNetworkAccessTrigger {
call_id: ctx.call_id.clone(),
tool_name: flat_tool_name(&ctx.tool_name).into_owned(),
command: req.command.clone(),
cwd: PathUri::from_abs_path(&req.cwd),
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
justification: req.justification.clone(),
tty: None,
},
command: req.hook_command.clone(),
environment_id: req.turn_environment.selection.environment_id.clone(),
permission_profile: req.turn_environment.permission_profile().clone(),
})
}
async fn run(
&mut self,
req: &ShellRequest,
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
) -> Result<ExecToolCallOutput, ToolError> {
let session_shell = ctx.session.user_shell();
let shell = req
.turn_environment
.shell
.as_ref()
.unwrap_or(session_shell.as_ref());
let shell_snapshot_location = req.turn_environment.shell_snapshot(&req.cwd);
let (file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions();
let sandbox_permissions = sandbox_permissions_preserving_denied_reads(
req.sandbox_permissions,
&file_system_sandbox_policy,
);
let managed_network =
managed_network_for_sandbox_permissions(req.network.as_ref(), sandbox_permissions);
let mut env = exec_env_for_sandbox_permissions(&req.env, sandbox_permissions);
let explicit_env_overrides = req.explicit_env_overrides.clone();
let cwd = PathUri::from_abs_path(&req.cwd);
let metrics_sidecar = sidecar_for_command(
ctx,
&req.command,
&cwd,
req.turn_environment.environment.as_ref(),
)
.await;
if let Some(sidecar) = metrics_sidecar.as_ref() {
sidecar.install_output_env(&mut env);
}
#[cfg(unix)]
let (env, runtime_path_prepends) = {
let mut env = env;
let mut runtime_path_prepends = RuntimePathPrepends::default();
crate::tools::runtimes::apply_package_path_prepend(
&mut env,
&mut runtime_path_prepends,
);
if self.backend == ShellRuntimeBackend::ShellCommandZshFork
&& let Some(shell_zsh_path) = ctx.session.services.shell_zsh_path.as_deref()
{
apply_zsh_fork_path_prepend(&mut env, &mut runtime_path_prepends, shell_zsh_path);
}
(env, runtime_path_prepends)
};
#[cfg(not(unix))]
let runtime_path_prepends = RuntimePathPrepends::default();
let command = maybe_wrap_shell_lc_with_snapshot(
&req.command,
shell,
shell_snapshot_location.as_ref(),
&explicit_env_overrides,
&env,
&runtime_path_prepends,
);
let command = disable_powershell_profile_for_elevated_windows_sandbox(
&command,
req.shell_type.as_ref(),
attempt.sandbox_requested,
attempt.windows_sandbox_level,
);
let command = if matches!(shell.shell_type, ShellType::PowerShell) {
prefix_powershell_script_with_utf8(&command)
} else {
command
};
let zsh_fork_output = if self.backend == ShellRuntimeBackend::ShellCommandZshFork {
match zsh_fork_backend::maybe_run_shell_command(
req,
attempt,
ctx,
&command,
metrics_sidecar.as_ref(),
)
.await?
{
Some(out) => Some(out),
None => {
tracing::warn!(
"ZshFork backend specified, but conditions for using it were not met, falling back to normal execution",
);
None
}
}
} else {
None
};
let out = if let Some(out) = zsh_fork_output {
out
} else {
let sidecar_permissions = metrics_sidecar
.as_ref()
.map(PluginMetricsSidecar::additional_permissions);
let additional_permissions = merge_permission_profiles(
req.additional_permissions.as_ref(),
sidecar_permissions.as_ref(),
);
let command = build_sandbox_command(&command, &req.cwd, &env, additional_permissions)?;
let mut expiration: crate::exec::ExecExpiration = req.timeout_ms.into();
expiration = expiration.with_cancellation(req.cancellation_token.clone());
if let Some(cancellation) = attempt.network_denial_cancellation_token.clone() {
expiration = expiration.with_cancellation(cancellation);
}
let options = ExecOptions {
expiration,
capture_policy: ExecCapturePolicy::ShellTool,
};
let env = attempt
.env_for(
command,
options,
managed_network,
Some(&req.turn_environment.selection.environment_id),
)
.map_err(ToolError::Codex)?;
execute_env(env, Self::stdout_stream(ctx))
.await
.map_err(ToolError::Codex)?
};
finish_and_track_measurements(
metrics_sidecar,
out.exit_code,
&ctx.session,
&ctx.step_context.turn,
&ctx.call_id,
)
.await;
Ok(out)
}
}
#[cfg(test)]
#[path = "shell_tests.rs"]
mod tests;
@@ -0,0 +1,3 @@
#[cfg(unix)]
pub(crate) mod unix_escalation;
pub(crate) mod zsh_fork_backend;
@@ -1,36 +1,23 @@
use super::ShellRequest;
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecExpiration;
use crate::exec::cancel_when_either;
use crate::exec::is_likely_sandbox_denied;
use crate::guardian::GuardianReviewContext;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::ExecRequest;
use crate::sandboxing::SandboxPermissions;
use crate::shell::ShellType;
use crate::tools::approvals::ApprovalAction;
use crate::tools::approvals::ApprovalContext;
use crate::tools::runtimes::build_sandbox_command;
use crate::tools::runtimes::exec_env_for_sandbox_permissions;
use crate::tools::runtimes::prepend_zsh_fork_bin_to_path;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::managed_network_for_sandbox_permissions;
use crate::tools::sandboxing::sandbox_permissions_preserving_denied_reads;
use crate::tools::sandboxing::unsandboxed_execution_allowed;
use codex_core_plugins::PluginMetricsSidecar;
use codex_execpolicy::Decision;
use codex_execpolicy::Evaluation;
use codex_execpolicy::MatchOptions;
use codex_execpolicy::Policy;
use codex_execpolicy::RuleMatch;
use codex_features::Feature;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::error::CodexErr;
use codex_protocol::error::SandboxErr;
use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::exec_output::StreamOutput;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
@@ -42,8 +29,6 @@ use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxTransformRequest;
use codex_sandboxing::SandboxType;
use codex_sandboxing::SandboxablePreference;
use codex_sandboxing::policy_transforms::merge_permission_profiles;
use codex_sandboxing::record_filesystem_sandbox_violation;
use codex_shell_command::bash::parse_shell_lc_plain_commands;
use codex_shell_escalation::EscalateServer;
use codex_shell_escalation::EscalationDecision;
@@ -52,7 +37,6 @@ use codex_shell_escalation::EscalationPermissions;
use codex_shell_escalation::EscalationPolicy;
use codex_shell_escalation::EscalationPolicyFuture;
use codex_shell_escalation::EscalationSession;
use codex_shell_escalation::ExecParams;
use codex_shell_escalation::ExecResult;
use codex_shell_escalation::PreparedExec;
use codex_shell_escalation::ResolvedPermissionProfile;
@@ -66,7 +50,6 @@ use std::collections::HashMap;
use std::io;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use tracing::error;
@@ -99,178 +82,6 @@ fn approval_sandbox_permissions(
}
}
pub(super) async fn try_run_zsh_fork(
req: &ShellRequest,
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
command: &[String],
metrics_sidecar: Option<&PluginMetricsSidecar>,
) -> Result<Option<ExecToolCallOutput>, ToolError> {
let Some(shell_zsh_path) = ctx.session.services.shell_zsh_path.as_ref() else {
tracing::warn!("ZshFork backend specified, but shell_zsh_path is not configured.");
return Ok(None);
};
if !ctx.session.features().enabled(Feature::ShellZshFork) {
tracing::warn!("ZshFork backend specified, but ShellZshFork feature is not enabled.");
return Ok(None);
}
if !matches!(ctx.session.user_shell().shell_type, ShellType::Zsh) {
tracing::warn!("ZshFork backend specified, but user shell is not Zsh.");
return Ok(None);
}
let (attempt_file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions();
let sandbox_permissions = sandbox_permissions_preserving_denied_reads(
req.sandbox_permissions,
&attempt_file_system_sandbox_policy,
);
let req = &ShellRequest {
sandbox_permissions,
..req.clone()
};
let mut env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions);
if let Some(sidecar) = metrics_sidecar {
sidecar.install_output_env(&mut env);
}
prepend_zsh_fork_bin_to_path(&mut env, shell_zsh_path);
let sidecar_permissions = metrics_sidecar.map(PluginMetricsSidecar::additional_permissions);
let additional_permissions = merge_permission_profiles(
req.additional_permissions.as_ref(),
sidecar_permissions.as_ref(),
);
let command = build_sandbox_command(command, &req.cwd, &env, additional_permissions)?;
let options = ExecOptions {
expiration: req.timeout_ms.into(),
capture_policy: ExecCapturePolicy::ShellTool,
};
let sandbox_exec_request = attempt
.env_for(
command,
options,
managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions),
Some(&req.turn_environment.selection.environment_id),
)
.map_err(ToolError::Codex)?;
let crate::sandboxing::ExecRequest {
command,
cwd: sandbox_cwd,
env: sandbox_env,
exec_server_env_config: _,
network: sandbox_network,
network_environment_id,
expiration: _sandbox_expiration,
capture_policy: _capture_policy,
sandbox,
windows_sandbox_policy_cwd: sandbox_policy_cwd,
windows_sandbox_workspace_roots,
windows_sandbox_level,
windows_sandbox_private_desktop: _windows_sandbox_private_desktop,
permission_profile,
windows_sandbox_filesystem_overrides: _windows_sandbox_filesystem_overrides,
arg0,
exec_server_sandbox: _,
exec_server_enforce_managed_network: _,
exec_server_managed_network: _,
exec_server_network_proxy: _,
} = sandbox_exec_request;
let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?;
let effective_timeout = Duration::from_millis(
req.timeout_ms
.unwrap_or(crate::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS),
);
let exec_policy = Arc::new(RwLock::new(
ctx.session
.services
.exec_policy
.current_for_environment(
req.turn_environment.config().exec_policy.as_ref(),
ctx.step_context.turn.allow_prefix_rules(),
)
.as_ref()
.clone(),
));
// TODO(anp): Keep PathUri through the shell escalation boundary.
let sandbox_cwd = sandbox_cwd
.to_abs_path()
.map_err(|err| ToolError::Rejected(err.to_string()))?;
// TODO(anp): Keep PathUri through the shell sandbox policy boundary.
let sandbox_policy_cwd = sandbox_policy_cwd
.to_abs_path()
.map_err(|err| ToolError::Rejected(err.to_string()))?;
let command_executor = CoreShellCommandExecutor {
command,
cwd: sandbox_cwd,
permission_profile,
sandbox,
env: sandbox_env,
network: sandbox_network,
network_environment_id,
windows_sandbox_level,
arg0,
sandbox_policy_cwd,
windows_sandbox_workspace_roots,
codex_linux_sandbox_exe: ctx.step_context.turn.config.codex_linux_sandbox_exe.clone(),
use_legacy_landlock: ctx.step_context.turn.config.features.use_legacy_landlock(),
};
let main_execve_wrapper_exe = ctx
.session
.services
.main_execve_wrapper_exe
.clone()
.ok_or_else(|| {
ToolError::Rejected(
"zsh fork feature enabled, but execve wrapper is not configured".to_string(),
)
})?;
let exec_params = ExecParams {
command: script,
workdir: req.cwd.to_string_lossy().to_string(),
timeout_ms: Some(effective_timeout.as_millis() as u64),
login: Some(login),
};
// Note that Stopwatch starts immediately upon creation, so currently we try
// to minimize the time between creating the Stopwatch and starting the
// escalation server.
let stopwatch = Stopwatch::new(effective_timeout);
let mut cancel_token = stopwatch.cancellation_token();
if let Some(cancellation) = attempt.network_denial_cancellation_token.clone() {
cancel_token = cancel_when_either(cancel_token, cancellation);
}
let approval_sandbox_permissions = approval_sandbox_permissions(
req.sandbox_permissions,
req.additional_permissions_preapproved,
);
let escalation_policy = CoreShellActionProvider {
policy: Arc::clone(&exec_policy),
session: Arc::clone(&ctx.session),
review_context: GuardianReviewContext::from(&ctx.step_context),
call_id: ctx.call_id.clone(),
environment_id: req.turn_environment.selection.environment_id.clone(),
source: GuardianCommandSource::Shell,
tool_name: ctx.tool_name.clone(),
approval_policy: ctx.step_context.turn.approval_policy(),
permission_profile: command_executor.permission_profile.clone(),
sandbox_permissions: req.sandbox_permissions,
approval_sandbox_permissions,
prompt_permissions: req.additional_permissions.clone(),
stopwatch: stopwatch.clone(),
};
let escalate_server = EscalateServer::new(
shell_zsh_path.clone(),
main_execve_wrapper_exe,
escalation_policy,
);
let exec_result = escalate_server
.exec(exec_params, cancel_token, Arc::new(command_executor))
.await
.map_err(|err| ToolError::Rejected(err.to_string()))?;
map_exec_result(attempt.sandbox, exec_result).map(Some)
}
pub(crate) async fn prepare_unified_exec_zsh_fork(
req: &crate::tools::runtimes::unified_exec::UnifiedExecRequest,
_attempt: &SandboxAttempt<'_>,
@@ -1024,36 +835,6 @@ fn extract_shell_script(command: &[String]) -> Result<ParsedShellCommand, ToolEr
))
}
fn map_exec_result(
sandbox: SandboxType,
result: ExecResult,
) -> Result<ExecToolCallOutput, ToolError> {
let output = ExecToolCallOutput {
exit_code: result.exit_code,
stdout: StreamOutput::new(result.stdout.clone()),
stderr: StreamOutput::new(result.stderr.clone()),
aggregated_output: StreamOutput::new(result.output.clone()),
duration: result.duration,
timed_out: result.timed_out,
};
if result.timed_out {
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout {
output: Box::new(output),
})));
}
if is_likely_sandbox_denied(sandbox, &output) {
record_filesystem_sandbox_violation(sandbox, &output);
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output: Box::new(output),
network_policy_decision: None,
})));
}
Ok(output)
}
/// Convert an intercepted exec `(program, argv)` into a command vector suitable
/// for display and policy parsing.
///
@@ -6,7 +6,6 @@ use super::commands_for_intercepted_exec_policy;
use super::evaluate_intercepted_exec_policy;
use super::extract_shell_script;
use super::join_program_and_argv;
use super::map_exec_result;
use crate::config::Constrained;
use crate::guardian::GuardianReviewContext;
use crate::sandboxing::SandboxPermissions;
@@ -36,7 +35,6 @@ use codex_sandboxing::SandboxType;
use codex_sandboxing::policy_transforms::effective_permission_profile;
use codex_shell_escalation::EscalationExecution;
use codex_shell_escalation::EscalationPermissions;
use codex_shell_escalation::ExecResult;
use codex_shell_escalation::ResolvedPermissionProfile;
use codex_tools::ToolName;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -277,26 +275,6 @@ fn commands_for_intercepted_exec_policy_preserves_unparsed_shell_wrappers() {
}
}
#[test]
fn map_exec_result_preserves_stdout_and_stderr() {
let out = map_exec_result(
SandboxType::None,
ExecResult {
exit_code: 0,
stdout: "out".to_string(),
stderr: "err".to_string(),
output: "outerr".to_string(),
duration: Duration::from_millis(1),
timed_out: false,
},
)
.unwrap();
assert_eq!(out.stdout.text, "out");
assert_eq!(out.stderr.text, "err");
assert_eq!(out.aggregated_output.text, "outerr");
}
#[test]
fn shell_request_escalation_execution_is_explicit() {
let requested_permissions = AdditionalPermissionProfile {
@@ -445,8 +423,8 @@ async fn preapproved_additional_permissions_escalate_intercepted_exec() -> anyho
review_context: GuardianReviewContext::from(Arc::new(turn_context)),
call_id: "preapproved-additional-permissions".to_string(),
environment_id: "local".to_string(),
source: GuardianCommandSource::Shell,
tool_name: ToolName::plain("shell_command"),
source: GuardianCommandSource::UnifiedExec,
tool_name: ToolName::plain("exec_command"),
approval_policy: AskForApproval::OnRequest,
permission_profile: permission_profile.clone(),
sandbox_permissions: SandboxPermissions::WithAdditionalPermissions,
@@ -613,8 +591,8 @@ async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Resul
review_context: GuardianReviewContext::from(turn_context),
call_id: "execve-hook-call".to_string(),
environment_id: "local".to_string(),
source: GuardianCommandSource::Shell,
tool_name: ToolName::plain("shell_command"),
source: GuardianCommandSource::UnifiedExec,
tool_name: ToolName::plain("exec_command"),
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
sandbox_permissions: SandboxPermissions::RequireEscalated,
@@ -824,8 +802,8 @@ prefix_rule(pattern = ["{cat_path_literal}"], decision = "allow")
review_context: GuardianReviewContext::from(Arc::new(turn_context)),
call_id: "deny-read-prefix-allow".to_string(),
environment_id: "local".to_string(),
source: GuardianCommandSource::Shell,
tool_name: ToolName::plain("shell_command"),
source: GuardianCommandSource::UnifiedExec,
tool_name: ToolName::plain("exec_command"),
approval_policy: AskForApproval::OnRequest,
permission_profile,
sandbox_permissions: SandboxPermissions::UseDefault,
@@ -861,8 +839,8 @@ async fn denied_reads_keep_granular_sandbox_rejection_for_escalation() -> anyhow
review_context: GuardianReviewContext::from(Arc::new(turn_context)),
call_id: "deny-read-granular-sandbox-reject".to_string(),
environment_id: "local".to_string(),
source: GuardianCommandSource::Shell,
tool_name: ToolName::plain("shell_command"),
source: GuardianCommandSource::UnifiedExec,
tool_name: ToolName::plain("exec_command"),
approval_policy: AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: false,
rules: true,
@@ -1,12 +1,9 @@
use super::ShellRequest;
use crate::sandboxing::ExecRequest;
use crate::tools::runtimes::unified_exec::UnifiedExecRequest;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::unified_exec::SpawnLifecycleHandle;
use codex_core_plugins::PluginMetricsSidecar;
use codex_protocol::exec_output::ExecToolCallOutput;
use codex_tools::ZshForkConfig;
pub(crate) struct PreparedUnifiedExecSpawn {
@@ -14,21 +11,6 @@ pub(crate) struct PreparedUnifiedExecSpawn {
pub(crate) spawn_lifecycle: SpawnLifecycleHandle,
}
/// Runs the zsh-fork shell-command backend when this request should be handled
/// by executable-level escalation instead of the default shell runtime.
///
/// Returns `Ok(None)` when the current platform or request shape should fall
/// back to the normal shell-command path.
pub(crate) async fn maybe_run_shell_command(
req: &ShellRequest,
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
command: &[String],
metrics_sidecar: Option<&PluginMetricsSidecar>,
) -> Result<Option<ExecToolCallOutput>, ToolError> {
imp::maybe_run_shell_command(req, attempt, ctx, command, metrics_sidecar).await
}
/// Prepares unified exec to launch through the zsh-fork backend when the
/// request matches a wrapped `zsh -c/-lc` command on a supported platform.
///
@@ -73,16 +55,6 @@ mod imp {
}
}
pub(super) async fn maybe_run_shell_command(
req: &ShellRequest,
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
command: &[String],
metrics_sidecar: Option<&PluginMetricsSidecar>,
) -> Result<Option<ExecToolCallOutput>, ToolError> {
unix_escalation::try_run_zsh_fork(req, attempt, ctx, command, metrics_sidecar).await
}
pub(super) async fn maybe_prepare_unified_exec(
req: &UnifiedExecRequest,
attempt: &SandboxAttempt<'_>,
@@ -116,17 +88,6 @@ mod imp {
mod imp {
use super::*;
pub(super) async fn maybe_run_shell_command(
req: &ShellRequest,
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
command: &[String],
metrics_sidecar: Option<&PluginMetricsSidecar>,
) -> Result<Option<ExecToolCallOutput>, ToolError> {
let _ = (req, attempt, ctx, command, metrics_sidecar);
Ok(None)
}
pub(super) async fn maybe_prepare_unified_exec(
req: &UnifiedExecRequest,
attempt: &SandboxAttempt<'_>,
@@ -1,81 +0,0 @@
use super::*;
use crate::config::PermissionProfileSnapshot;
use crate::environment_selection::EnvironmentConfigOrigin;
use crate::tools::approvals::ApprovalCacheKey;
use codex_exec_server::Environment;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::EnvironmentConfig;
use codex_protocol::protocol::EnvironmentConfigState;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use std::sync::Arc;
#[tokio::test]
async fn approval_key_uses_path_uri_and_includes_environment_id() {
let cwd = AbsolutePathBuf::try_from(std::env::current_dir().expect("read current dir"))
.expect("current dir is absolute");
let mut request = ShellRequest {
command: vec!["echo".to_string(), "hello".to_string()],
turn_environment: TurnEnvironment::new(
TurnEnvironmentSelection {
environment_id: "remote".to_string(),
cwd: PathUri::from_abs_path(&cwd),
workspace_roots: Vec::new(),
config: EnvironmentConfigState::Ready(EnvironmentConfig {
allow_login_shell: true,
permission_profile: PermissionProfileSnapshot::legacy(
PermissionProfile::read_only(),
),
shell_environment_policy: Default::default(),
exec_policy: None,
mcp_policy: None,
network_policy: None,
selected_capability_roots: Vec::new(),
}),
},
EnvironmentConfigOrigin::Thread,
Arc::new(Environment::default_for_tests()),
/*shell*/ None,
),
shell_type: None,
hook_command: "echo hello".to_string(),
cwd: cwd.clone(),
timeout_ms: None,
cancellation_token: CancellationToken::new(),
env: HashMap::new(),
explicit_env_overrides: HashMap::new(),
network: None,
sandbox_permissions: SandboxPermissions::UseDefault,
additional_permissions: None,
#[cfg(unix)]
additional_permissions_preapproved: false,
justification: None,
exec_approval_requirement: ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: None,
},
};
let runtime = ShellRuntime::for_shell_command(ShellRuntimeBackend::ShellCommandClassic);
let original_key = runtime
.approval_action(&request, "call-1")
.expect("build approval action")
.cache_keys();
assert_eq!(
original_key,
vec![ApprovalCacheKey::Shell(ApprovalKey {
environment_id: "remote".to_string(),
command: request.command.clone(),
cwd: PathUri::from_abs_path(&cwd),
sandbox_permissions: request.sandbox_permissions,
additional_permissions: request.additional_permissions.clone(),
})]
);
request.turn_environment.selection.environment_id = "other".to_string();
let other_key = runtime
.approval_action(&request, "call-1")
.expect("build approval action")
.cache_keys();
assert_ne!(original_key, other_key);
}
@@ -16,7 +16,6 @@ use crate::sandboxing::SandboxPermissions;
use crate::session::turn_context::TurnEnvironment;
use crate::shell::ShellType;
use crate::tools::flat_tool_name;
use crate::tools::network_approval::NetworkApprovalMode;
use crate::tools::network_approval::NetworkApprovalSpec;
use crate::tools::runtimes::RuntimePathPrepends;
#[cfg(unix)]
@@ -226,7 +225,6 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
managed_network_for_sandbox_permissions(req.network.as_ref(), sandbox_permissions)?;
Some(NetworkApprovalSpec {
network: Some(network.clone()),
mode: NetworkApprovalMode::Deferred,
trigger: GuardianNetworkAccessTrigger {
call_id: ctx.call_id.clone(),
tool_name: flat_tool_name(&ctx.tool_name).into_owned(),
+1 -26
View File
@@ -25,8 +25,6 @@ use crate::tools::handlers::RequestPermissionsHandler;
use crate::tools::handlers::RequestPluginInstallHandler;
use crate::tools::handlers::RequestUserInputHandler;
use crate::tools::handlers::SendUserMessageAsyncHandler;
use crate::tools::handlers::ShellCommandHandler;
use crate::tools::handlers::ShellCommandHandlerOptions;
use crate::tools::handlers::SleepHandler;
use crate::tools::handlers::TestSyncHandler;
use crate::tools::handlers::ToolSearchHandlerCache;
@@ -92,7 +90,6 @@ use codex_tools::collect_code_mode_exec_prompt_tool_definitions;
use codex_tools::collect_request_plugin_install_entries;
use codex_tools::default_namespace_description;
use codex_tools::request_user_input_available_modes;
use codex_tools::shell_command_backend_for_features;
use codex_tools::shell_type_for_model_and_features;
use futures::future::BoxFuture;
use std::collections::BTreeMap;
@@ -970,13 +967,6 @@ fn add_shell_tools(context: &CoreToolPlanContext<'_>, registry: &mut ToolRegistr
let allow_login_shell = any_environment_allows_login_shell(context.environments);
let exec_permission_approvals_enabled = features.enabled(Feature::ExecPermissionApprovals);
let include_environment_id = matches!(environment_mode, ToolEnvironmentMode::Multiple);
let supports_shell_command = context.environments.single_local_environment().is_some();
let shell_command_options = ShellCommandHandlerOptions {
backend_config: shell_command_backend_for_features(features),
allow_login_shell,
exec_permission_approvals_enabled,
};
match shell_type_for_model_and_features(&turn_context.model_info, features) {
ConfigShellToolType::UnifiedExec => {
registry.add(ExecCommandHandler::new(ExecCommandHandlerOptions {
@@ -989,24 +979,9 @@ fn add_shell_tools(context: &CoreToolPlanContext<'_>, registry: &mut ToolRegistr
),
}));
registry.add(WriteStdinHandler);
if supports_shell_command {
// Keep the legacy shell tool registered while unified exec is
// model-visible.
registry.add_with_exposure(
ShellCommandHandler::new(shell_command_options),
ToolExposure::Hidden,
);
}
}
ConfigShellToolType::Disabled => {}
ConfigShellToolType::Default
| ConfigShellToolType::Local
| ConfigShellToolType::ShellCommand => {
if supports_shell_command {
registry.add(ShellCommandHandler::new(shell_command_options));
}
}
ConfigShellToolType::Default | ConfigShellToolType::Local => {}
}
}
+40 -55
View File
@@ -660,34 +660,28 @@ async fn request_user_input_stays_direct_in_code_mode_only() {
}
#[tokio::test]
async fn shell_family_registers_visible_unified_exec_and_hidden_legacy_shell() {
async fn shell_family_registers_only_unified_exec_tools() {
let plan = probe(|turn| {
set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]);
set_feature(turn, Feature::ShellZshFork, /*enabled*/ false);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand;
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec;
})
.await;
plan.assert_visible_contains(&["exec_command", "write_stdin"]);
plan.assert_visible_lacks(&["shell_command"]);
plan.assert_registered_contains(&["exec_command", "write_stdin", "shell_command"]);
assert_eq!(plan.exposure("shell_command"), ToolExposure::Hidden);
plan.assert_registered_contains(&["exec_command", "write_stdin"]);
assert!(has_parameter(plan.visible_spec("exec_command"), "shell"));
}
#[tokio::test]
async fn login_shell_parameter_follows_selected_environment() {
for (tool_name, guardian) in [
("shell_command", false),
("exec_command", false),
("exec_command", true),
] {
for guardian in [false, true] {
for allow_login_shell in [false, true] {
let plan = probe(|turn| {
set_feature(turn, Feature::ShellTool, /*enabled*/ true);
set_feature(turn, Feature::UnifiedExec, tool_name == "exec_command");
set_feature(turn, Feature::UnifiedExec, /*enabled*/ true);
set_feature(turn, Feature::ShellZshFork, /*enabled*/ false);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand;
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec;
update_config(turn, |config| {
config.permissions.allow_login_shell = !allow_login_shell;
});
@@ -711,7 +705,7 @@ async fn login_shell_parameter_follows_selected_environment() {
.await;
assert_eq!(
has_parameter(plan.visible_spec(tool_name), "login"),
has_parameter(plan.visible_spec("exec_command"), "login"),
allow_login_shell
);
}
@@ -740,11 +734,10 @@ async fn login_shell_parameter_is_available_when_any_environment_allows_it() {
}
#[tokio::test]
async fn shell_command_is_not_registered_without_a_single_local_environment() {
async fn disabling_shell_tools_disables_command_tools_for_all_environments() {
let remote_environment = probe(|turn| {
set_feature(turn, Feature::ShellTool, /*enabled*/ true);
set_feature(turn, Feature::UnifiedExec, /*enabled*/ false);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand;
set_feature(turn, Feature::ShellTool, /*enabled*/ false);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec;
let TurnEnvironmentState::Ready(environment) = turn
.environments
@@ -767,64 +760,63 @@ async fn shell_command_is_not_registered_without_a_single_local_environment() {
remote_environment.assert_registered_lacks(&["shell_command", "exec_command", "write_stdin"]);
let multiple_local_environments = probe(|turn| {
set_feature(turn, Feature::ShellTool, /*enabled*/ true);
set_feature(turn, Feature::UnifiedExec, /*enabled*/ false);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand;
set_feature(turn, Feature::ShellTool, /*enabled*/ false);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec;
duplicate_primary_environment(turn);
})
.await;
multiple_local_environments.assert_visible_lacks(&["shell_command"]);
multiple_local_environments.assert_registered_lacks(&["shell_command"]);
multiple_local_environments.assert_visible_lacks(&[
"shell_command",
"exec_command",
"write_stdin",
]);
multiple_local_environments.assert_registered_lacks(&[
"shell_command",
"exec_command",
"write_stdin",
]);
}
#[tokio::test]
async fn dynamic_tools_cannot_reclaim_the_reserved_shell_command_name() {
async fn dynamic_tools_cannot_reclaim_the_reserved_exec_command_name() {
let plan = probe_with(
duplicate_primary_environment,
ToolPlanInputs {
dynamic_tools: vec![
dynamic_tool(
/*namespace*/ None,
"shell_command",
/*defer_loading*/ false,
),
dynamic_tool(
Some("client"),
"shell_command",
"exec_command",
/*defer_loading*/ false,
),
dynamic_tool(Some("client"), "exec_command", /*defer_loading*/ false),
],
..ToolPlanInputs::default()
},
)
.await;
plan.assert_visible_lacks(&["shell_command"]);
plan.assert_registered_lacks(&["shell_command"]);
plan.assert_visible_contains(&["exec_command"]);
plan.assert_registered_contains(&["exec_command"]);
plan.assert_visible_contains(&["client"]);
plan.assert_registered_contains(
&[&ToolName::namespaced("client", "shell_command").to_string()],
);
plan.assert_registered_contains(&[&ToolName::namespaced("client", "exec_command").to_string()]);
assert_eq!(
plan.namespace_function_names("client"),
&["shell_command".to_string()]
&["exec_command".to_string()]
);
}
#[tokio::test]
async fn shell_zsh_fork_stays_standalone_until_unified_exec_composition_is_enabled() {
let standalone = probe(|turn| {
async fn shell_zsh_fork_keeps_unified_exec_available() {
let without_composition = probe(|turn| {
set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]);
set_feature(turn, Feature::ShellZshFork, /*enabled*/ true);
set_feature(turn, Feature::UnifiedExecZshFork, /*enabled*/ false);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand;
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec;
})
.await;
standalone.assert_visible_contains(&["shell_command"]);
standalone.assert_visible_lacks(&["exec_command", "write_stdin"]);
standalone.assert_registered_contains(&["shell_command"]);
standalone.assert_registered_lacks(&["exec_command", "write_stdin"]);
without_composition.assert_visible_contains(&["exec_command", "write_stdin"]);
without_composition.assert_registered_contains(&["exec_command", "write_stdin"]);
let composed = probe(|turn| {
set_features(
@@ -836,19 +828,12 @@ async fn shell_zsh_fork_stays_standalone_until_unified_exec_composition_is_enabl
Feature::UnifiedExecZshFork,
],
);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand;
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec;
})
.await;
if codex_utils_pty::conpty_supported() {
composed.assert_visible_contains(&["exec_command", "write_stdin"]);
composed.assert_visible_lacks(&["shell_command"]);
composed.assert_registered_contains(&["exec_command", "write_stdin", "shell_command"]);
assert_eq!(composed.exposure("shell_command"), ToolExposure::Hidden);
} else {
composed.assert_visible_contains(&["shell_command"]);
composed.assert_visible_lacks(&["exec_command", "write_stdin"]);
}
composed.assert_visible_contains(&["exec_command", "write_stdin"]);
composed.assert_registered_contains(&["exec_command", "write_stdin"]);
}
#[tokio::test]
@@ -958,15 +943,15 @@ async fn environment_count_controls_environment_backed_tools() {
})
.await;
no_environment.assert_visible_lacks(&[
"shell_command",
"exec_command",
"write_stdin",
"apply_patch",
"view_image",
"request_permissions",
]);
no_environment.assert_registered_lacks(&[
"shell_command",
"exec_command",
"write_stdin",
"apply_patch",
"view_image",
"request_permissions",
@@ -1363,7 +1348,7 @@ async fn unified_tool_runtimes_preserve_source_order_and_collision_priority() {
|turn| {
set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]);
set_feature(turn, Feature::ShellZshFork, /*enabled*/ false);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand;
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec;
},
ToolPlanInputs {
tool_runtimes: vec![mcp_runtime(