feat: add config allow_login_shell (#12312)

This commit is contained in:
jif-oai 2026-02-20 20:02:24 +00:00 committed by GitHub
parent 67e802e26b
commit 5034d4bd89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 244 additions and 56 deletions

View file

@ -1392,6 +1392,10 @@
],
"description": "Agent-related settings (thread limits, etc.)."
},
"allow_login_shell": {
"description": "Whether the model may request a login shell for shell-based tools. Default to `true`\n\nIf `true`, the model may request a login shell (`login = true`), and omitting `login` defaults to using a login shell. If `false`, the model can never use a login shell: `login = true` requests are rejected, and omitting `login` defaults to a non-login shell.",
"type": "boolean"
},
"analytics": {
"allOf": [
{

View file

@ -609,6 +609,7 @@ impl TurnContext {
features: &features,
web_search_mode: self.tools_config.web_search_mode,
})
.with_allow_login_shell(self.tools_config.allow_login_shell)
.with_agent_roles(config.agent_roles.clone());
Self {
@ -941,6 +942,7 @@ impl Session {
features: &per_turn_config.features,
web_search_mode: Some(per_turn_config.web_search_mode.value()),
})
.with_allow_login_shell(per_turn_config.permissions.allow_login_shell)
.with_agent_roles(per_turn_config.agent_roles.clone());
let cwd = session_configuration.cwd.clone();
@ -4122,6 +4124,7 @@ async fn spawn_review_thread(
features: &review_features,
web_search_mode: Some(review_web_search_mode),
})
.with_allow_login_shell(config.permissions.allow_login_shell)
.with_agent_roles(config.agent_roles.clone());
let review_prompt = resolved.prompt.clone();

View file

@ -139,6 +139,15 @@ pub struct Permissions {
pub sandbox_policy: Constrained<SandboxPolicy>,
/// Effective network configuration applied to all spawned processes.
pub network: Option<NetworkProxySpec>,
/// Whether the model may request a login shell for shell-based tools.
/// Default to `true`
///
/// If `true`, the model may request a login shell (`login = true`), and
/// omitting `login` defaults to using a login shell.
/// If `false`, the model can never use a login shell: `login = true`
/// requests are rejected, and omitting `login` defaults to a non-login
/// shell.
pub allow_login_shell: bool,
/// Policy used to build process environments for shell/unified exec.
pub shell_environment_policy: ShellEnvironmentPolicy,
/// Effective Windows sandbox mode derived from `[windows].sandbox` or
@ -952,6 +961,16 @@ pub struct ConfigToml {
#[serde(default)]
pub shell_environment_policy: ShellEnvironmentPolicyToml,
/// Whether the model may request a login shell for shell-based tools.
/// Default to `true`
///
/// If `true`, the model may request a login shell (`login = true`), and
/// omitting `login` defaults to using a login shell.
/// If `false`, the model can never use a login shell: `login = true`
/// requests are rejected, and omitting `login` defaults to a non-login
/// shell.
pub allow_login_shell: Option<bool>,
/// Sandbox mode to use.
pub sandbox_mode: Option<SandboxMode>,
@ -1710,6 +1729,7 @@ impl Config {
.clone();
let shell_environment_policy = cfg.shell_environment_policy.into();
let allow_login_shell = cfg.allow_login_shell.unwrap_or(true);
let history = cfg.history.unwrap_or_default();
@ -1950,6 +1970,7 @@ impl Config {
approval_policy: constrained_approval_policy.value,
sandbox_policy: constrained_sandbox_policy.value,
network,
allow_login_shell,
shell_environment_policy,
windows_sandbox_mode,
macos_seatbelt_profile_extensions: None,
@ -4519,6 +4540,7 @@ model_verbosity = "high"
approval_policy: Constrained::allow_any(AskForApproval::Never),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
macos_seatbelt_profile_extensions: None,
@ -4637,6 +4659,7 @@ model_verbosity = "high"
approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
macos_seatbelt_profile_extensions: None,
@ -4753,6 +4776,7 @@ model_verbosity = "high"
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
macos_seatbelt_profile_extensions: None,
@ -4855,6 +4879,7 @@ model_verbosity = "high"
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
macos_seatbelt_profile_extensions: None,
@ -5429,6 +5454,27 @@ mcp_oauth_callback_port = 5678
Ok(())
}
#[test]
fn config_loads_allow_login_shell_from_toml() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg: ConfigToml = toml::from_str(
r#"
model = "gpt-5.1"
allow_login_shell = false
"#,
)
.expect("TOML deserialization should succeed for allow_login_shell");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.path().to_path_buf(),
)?;
assert!(!config.permissions.allow_login_shell);
Ok(())
}
#[test]
fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> {
let codex_home = TempDir::new()?;

View file

@ -84,22 +84,28 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec<Stri
"shell_command" => serde_json::from_str::<ShellCommandToolCallParams>(arguments)
.ok()
.map(|params| {
if !invocation.turn.tools_config.allow_login_shell && params.login == Some(true) {
return (Vec::new(), invocation.turn.resolve_path(params.workdir));
}
let use_login_shell = params
.login
.unwrap_or(invocation.turn.tools_config.allow_login_shell);
let command = invocation
.session
.user_shell()
.derive_exec_args(&params.command, params.login.unwrap_or(true));
.derive_exec_args(&params.command, use_login_shell);
(command, invocation.turn.resolve_path(params.workdir))
}),
"exec_command" => serde_json::from_str::<ExecCommandArgs>(arguments)
.ok()
.map(|params| {
(
crate::tools::handlers::unified_exec::get_command(
&params,
invocation.session.user_shell(),
),
invocation.turn.resolve_path(params.workdir),
.and_then(|params| {
let command = crate::tools::handlers::unified_exec::get_command(
&params,
invocation.session.user_shell(),
invocation.turn.tools_config.allow_login_shell,
)
.ok()?;
Some((command, invocation.turn.resolve_path(params.workdir)))
}),
_ => None,
}

View file

@ -1372,6 +1372,7 @@ permissions:
}
),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
macos_seatbelt_profile_extensions,
@ -1408,6 +1409,7 @@ permissions: {}
crate::protocol::SandboxPolicy::new_read_only_policy(),
),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
macos_seatbelt_profile_extensions: Some(
@ -1421,6 +1423,7 @@ permissions: {}
crate::protocol::SandboxPolicy::new_read_only_policy(),
),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
macos_seatbelt_profile_extensions: None,
@ -1515,6 +1518,7 @@ permissions:
crate::protocol::SandboxPolicy::new_read_only_policy(),
),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
macos_seatbelt_profile_extensions: None,

View file

@ -112,6 +112,7 @@ pub(crate) fn compile_permission_profile(
approval_policy: Constrained::allow_any(AskForApproval::Never),
sandbox_policy: Constrained::allow_any(sandbox_policy),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
macos_seatbelt_profile_extensions,
@ -347,6 +348,7 @@ mod tests {
exclude_slash_tmp: false,
}),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
#[cfg(target_os = "macos")]
@ -391,6 +393,7 @@ mod tests {
approval_policy: Constrained::allow_any(AskForApproval::Never),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
#[cfg(target_os = "macos")]
@ -439,6 +442,7 @@ mod tests {
},
}),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
#[cfg(target_os = "macos")]

View file

@ -63,8 +63,20 @@ impl ShellHandler {
}
impl ShellCommandHandler {
fn base_command(shell: &Shell, command: &str, login: Option<bool>) -> Vec<String> {
let use_login_shell = login.unwrap_or(true);
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))
}
fn base_command(shell: &Shell, command: &str, use_login_shell: bool) -> Vec<String> {
shell.derive_exec_args(command, use_login_shell)
}
@ -73,11 +85,13 @@ impl ShellCommandHandler {
session: &crate::codex::Session,
turn_context: &TurnContext,
thread_id: ThreadId,
) -> ExecParams {
allow_login_shell: bool,
) -> Result<ExecParams, FunctionCallError> {
let shell = session.user_shell();
let command = Self::base_command(shell.as_ref(), &params.command, params.login);
let use_login_shell = Self::resolve_use_login_shell(params.login, allow_login_shell)?;
let command = Self::base_command(shell.as_ref(), &params.command, use_login_shell);
ExecParams {
Ok(ExecParams {
command,
cwd: turn_context.resolve_path(params.workdir.clone()),
expiration: params.timeout_ms.into(),
@ -87,7 +101,7 @@ impl ShellCommandHandler {
windows_sandbox_level: turn_context.windows_sandbox_level,
justification: params.justification.clone(),
arg0: None,
}
})
}
}
@ -183,8 +197,15 @@ impl ToolHandler for ShellCommandHandler {
serde_json::from_str::<ShellCommandToolCallParams>(arguments)
.map(|params| {
let use_login_shell = match Self::resolve_use_login_shell(
params.login,
invocation.turn.tools_config.allow_login_shell,
) {
Ok(use_login_shell) => use_login_shell,
Err(_) => return true,
};
let shell = invocation.session.user_shell();
let command = Self::base_command(shell.as_ref(), &params.command, params.login);
let command = Self::base_command(shell.as_ref(), &params.command, use_login_shell);
!is_known_safe_command(&command)
})
.unwrap_or(true)
@ -213,7 +234,8 @@ impl ToolHandler for ShellCommandHandler {
session.as_ref(),
turn.as_ref(),
session.conversation_id,
);
turn.tools_config.allow_login_shell,
)?;
ShellHandler::run_exec_like(RunExecLikeArgs {
tool_name,
exec_params,
@ -439,7 +461,9 @@ mod tests {
&session,
&turn_context,
session.conversation_id,
);
true,
)
.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);
@ -464,18 +488,57 @@ mod tests {
shell_snapshot,
};
let login_command =
ShellCommandHandler::base_command(&shell, "echo login shell", Some(true));
let login_command = ShellCommandHandler::base_command(&shell, "echo login shell", true);
assert_eq!(
login_command,
shell.derive_exec_args("echo login shell", true)
);
let non_login_command =
ShellCommandHandler::base_command(&shell, "echo non login shell", Some(false));
ShellCommandHandler::base_command(&shell, "echo non login shell", false);
assert_eq!(
non_login_command,
shell.derive_exec_args("echo non 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 params = ShellCommandToolCallParams {
command: "echo hello".to_string(),
workdir: None,
login: None,
timeout_ms: None,
sandbox_permissions: None,
prefix_rule: None,
justification: None,
};
let exec_params = ShellCommandHandler::to_exec_params(
&params,
&session,
&turn_context,
session.conversation_id,
false,
)
.expect("non-login shells should still be allowed");
assert_eq!(
exec_params.command,
session.user_shell().derive_exec_args("echo hello", false)
);
}
#[test]
fn shell_command_handler_rejects_login_when_disallowed() {
let err = ShellCommandHandler::resolve_use_login_shell(Some(true), false)
.expect_err("explicit login should be rejected");
assert!(
err.to_string()
.contains("login shell is disabled by config"),
"unexpected error: {err}"
);
}
}

View file

@ -32,8 +32,8 @@ pub(crate) struct ExecCommandArgs {
pub(crate) workdir: Option<String>,
#[serde(default)]
shell: Option<String>,
#[serde(default = "default_login")]
login: bool,
#[serde(default)]
login: Option<bool>,
#[serde(default = "default_tty")]
tty: bool,
#[serde(default = "default_exec_yield_time_ms")]
@ -68,10 +68,6 @@ fn default_write_stdin_yield_time_ms() -> u64 {
250
}
fn default_login() -> bool {
true
}
fn default_tty() -> bool {
false
}
@ -98,7 +94,14 @@ impl ToolHandler for UnifiedExecHandler {
let Ok(params) = serde_json::from_str::<ExecCommandArgs>(arguments) else {
return true;
};
let command = get_command(&params, invocation.session.user_shell());
let command = match get_command(
&params,
invocation.session.user_shell(),
invocation.turn.tools_config.allow_login_shell,
) {
Ok(command) => command,
Err(_) => return true,
};
!is_known_safe_command(&command)
}
@ -129,7 +132,12 @@ impl ToolHandler for UnifiedExecHandler {
"exec_command" => {
let args: ExecCommandArgs = parse_arguments(&arguments)?;
let process_id = manager.allocate_process_id().await;
let command = get_command(&args, session.user_shell());
let command = get_command(
&args,
session.user_shell(),
turn.tools_config.allow_login_shell,
)
.map_err(FunctionCallError::RespondToModel)?;
let ExecCommandArgs {
workdir,
@ -238,7 +246,11 @@ impl ToolHandler for UnifiedExecHandler {
}
}
pub(crate) fn get_command(args: &ExecCommandArgs, session_shell: Arc<Shell>) -> Vec<String> {
pub(crate) fn get_command(
args: &ExecCommandArgs,
session_shell: Arc<Shell>,
allow_login_shell: bool,
) -> Result<Vec<String>, String> {
let model_shell = args.shell.as_ref().map(|shell_str| {
let mut shell = get_shell_by_model_provided_path(&PathBuf::from(shell_str));
shell.shell_snapshot = crate::shell::empty_shell_snapshot_receiver();
@ -246,8 +258,17 @@ pub(crate) fn get_command(args: &ExecCommandArgs, session_shell: Arc<Shell>) ->
});
let shell = model_shell.as_ref().unwrap_or(session_shell.as_ref());
let use_login_shell = match args.login {
Some(true) if !allow_login_shell => {
return Err(
"login shell is disabled by config; omit `login` or set it to false.".to_string(),
);
}
Some(use_login_shell) => use_login_shell,
None => allow_login_shell,
};
shell.derive_exec_args(&args.cmd, args.login)
Ok(shell.derive_exec_args(&args.cmd, use_login_shell))
}
fn format_response(response: &UnifiedExecResponse) -> String {
@ -283,6 +304,7 @@ fn format_response(response: &UnifiedExecResponse) -> String {
mod tests {
use super::*;
use crate::shell::default_user_shell;
use pretty_assertions::assert_eq;
use std::sync::Arc;
#[test]
@ -293,7 +315,8 @@ mod tests {
assert!(args.shell.is_none());
let command = get_command(&args, Arc::new(default_user_shell()));
let command =
get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?;
assert_eq!(command.len(), 3);
assert_eq!(command[2], "echo hello");
@ -308,7 +331,8 @@ mod tests {
assert_eq!(args.shell.as_deref(), Some("/bin/bash"));
let command = get_command(&args, Arc::new(default_user_shell()));
let command =
get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?;
assert_eq!(command.last(), Some(&"echo hello".to_string()));
if command
@ -328,7 +352,8 @@ mod tests {
assert_eq!(args.shell.as_deref(), Some("powershell"));
let command = get_command(&args, Arc::new(default_user_shell()));
let command =
get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?;
assert_eq!(command[2], "echo hello");
Ok(())
@ -342,9 +367,25 @@ mod tests {
assert_eq!(args.shell.as_deref(), Some("cmd"));
let command = get_command(&args, Arc::new(default_user_shell()));
let command =
get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?;
assert_eq!(command[2], "echo hello");
Ok(())
}
#[test]
fn test_get_command_rejects_explicit_login_when_disallowed() -> anyhow::Result<()> {
let json = r#"{"cmd": "echo hello", "login": true}"#;
let args: ExecCommandArgs = parse_arguments(json)?;
let err = get_command(&args, Arc::new(default_user_shell()), false)
.expect_err("explicit login should be rejected");
assert!(
err.contains("login shell is disabled by config"),
"unexpected error: {err}"
);
Ok(())
}
}

View file

@ -35,6 +35,7 @@ const SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE: &str =
#[derive(Debug, Clone)]
pub(crate) struct ToolsConfig {
pub shell_type: ConfigShellToolType,
pub allow_login_shell: bool,
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
pub web_search_mode: Option<WebSearchMode>,
pub agent_roles: BTreeMap<String, AgentRoleConfig>,
@ -96,6 +97,7 @@ impl ToolsConfig {
Self {
shell_type,
allow_login_shell: true,
apply_patch_tool_type,
web_search_mode: *web_search_mode,
agent_roles: BTreeMap::new(),
@ -112,6 +114,11 @@ impl ToolsConfig {
self.agent_roles = agent_roles;
self
}
pub fn with_allow_login_shell(mut self, allow_login_shell: bool) -> Self {
self.allow_login_shell = allow_login_shell;
self
}
}
/// Generic JSONSchema subset needed for our tool definitions
@ -211,7 +218,7 @@ fn create_approval_parameters() -> BTreeMap<String, JsonSchema> {
properties
}
fn create_exec_command_tool() -> ToolSpec {
fn create_exec_command_tool(allow_login_shell: bool) -> ToolSpec {
let mut properties = BTreeMap::from([
(
"cmd".to_string(),
@ -234,14 +241,6 @@ fn create_exec_command_tool() -> ToolSpec {
description: Some("Shell binary to launch. Defaults to the user's default shell.".to_string()),
},
),
(
"login".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(),
),
},
),
(
"tty".to_string(),
JsonSchema::Boolean {
@ -269,6 +268,16 @@ fn create_exec_command_tool() -> ToolSpec {
},
),
]);
if allow_login_shell {
properties.insert(
"login".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(),
),
},
);
}
properties.extend(create_approval_parameters());
ToolSpec::Function(ResponsesApiTool {
@ -385,7 +394,7 @@ Examples of valid command strings:
})
}
fn create_shell_command_tool() -> ToolSpec {
fn create_shell_command_tool(allow_login_shell: bool) -> ToolSpec {
let mut properties = BTreeMap::from([
(
"command".to_string(),
@ -402,6 +411,14 @@ fn create_shell_command_tool() -> ToolSpec {
},
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
},
),
]);
if allow_login_shell {
properties.insert(
"login".to_string(),
JsonSchema::Boolean {
description: Some(
@ -409,14 +426,8 @@ fn create_shell_command_tool() -> ToolSpec {
.to_string(),
),
},
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
},
),
]);
);
}
properties.extend(create_approval_parameters());
let description = if cfg!(windows) {
@ -1462,7 +1473,10 @@ pub(crate) fn build_specs(
builder.push_spec_with_parallel_support(ToolSpec::LocalShell {}, true);
}
ConfigShellToolType::UnifiedExec => {
builder.push_spec_with_parallel_support(create_exec_command_tool(), true);
builder.push_spec_with_parallel_support(
create_exec_command_tool(config.allow_login_shell),
true,
);
builder.push_spec(create_write_stdin_tool());
builder.register_handler("exec_command", unified_exec_handler.clone());
builder.register_handler("write_stdin", unified_exec_handler);
@ -1471,7 +1485,10 @@ pub(crate) fn build_specs(
// Do nothing.
}
ConfigShellToolType::ShellCommand => {
builder.push_spec_with_parallel_support(create_shell_command_tool(), true);
builder.push_spec_with_parallel_support(
create_shell_command_tool(config.allow_login_shell),
true,
);
}
}
@ -1816,7 +1833,7 @@ mod tests {
// Build expected from the same helpers used by the builder.
let mut expected: BTreeMap<String, ToolSpec> = BTreeMap::from([]);
for spec in [
create_exec_command_tool(),
create_exec_command_tool(true),
create_write_stdin_tool(),
PLAN_TOOL.clone(),
create_request_user_input_tool(),
@ -2790,7 +2807,7 @@ Examples of valid command strings:
#[test]
fn test_shell_command_tool() {
let tool = super::create_shell_command_tool();
let tool = super::create_shell_command_tool(true);
let ToolSpec::Function(ResponsesApiTool {
description, name, ..
}) = &tool