mirror of
https://github.com/openai/codex.git
synced 2026-10-28 08:04:55 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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, ¶ms.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(),
|
||||
¶ms.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(
|
||||
¶ms,
|
||||
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(
|
||||
¶ms,
|
||||
&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(
|
||||
¶ms,
|
||||
&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(
|
||||
¶ms,
|
||||
&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"),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user