Require explicit sandbox permissions with shell justifications (#36350)

## What changed

Reject `shell_command` and unified `exec_command` calls that provide a
`justification` without also specifying `sandbox_permissions`. The
model-visible error tells callers to explicitly request `require_escalated`
execution or omit the justification.

## Testing

- Add unit coverage for `shell_command` argument validation.
- Add integration coverage for both shell tool paths, including verification
  that rejected unified exec calls do not begin execution.

GitOrigin-RevId: 6af39d2c889af492440615eae12085b99a8b5169
This commit is contained in:
Mitsuhiro Kotake authored and copyberry committed 2026-07-31 15:34:03 +00:00
1 parent 3d1d26915a
commit 7b38c48da9
7 files changed
+178 -3

No files matched your search

+13
View File
@@ -89,6 +89,19 @@ where
})
}
fn resolve_sandbox_permissions(
sandbox_permissions: Option<SandboxPermissions>,
justification: Option<&str>,
) -> Result<SandboxPermissions, FunctionCallError> {
if justification.is_some() && sandbox_permissions.is_none() {
return Err(FunctionCallError::RespondToModel(
"`justification` requires an explicit `sandbox_permissions`; use `sandbox_permissions: \"require_escalated\"` for unsandboxed execution, or omit `justification`.".to_string(),
));
}
Ok(sandbox_permissions.unwrap_or_default())
}
fn updated_hook_command(updated_input: &Value) -> Result<&str, FunctionCallError> {
updated_input
.get("command")
@@ -16,6 +16,7 @@ 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;
@@ -106,6 +107,10 @@ impl ShellCommandHandler {
);
let active_permission_profile = turn_context.config.permissions.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,
@@ -115,7 +120,7 @@ impl ShellCommandHandler {
env,
network: turn_context.network.clone(),
network_environment_id: Some(turn_environment.environment_id.clone()),
sandbox_permissions: params.sandbox_permissions.unwrap_or_default(),
sandbox_permissions,
windows_sandbox_level: turn_context.windows_sandbox_level,
windows_sandbox_private_desktop: turn_context
.config
@@ -229,6 +229,45 @@ async fn shell_command_handler_defaults_to_non_login_when_disallowed() {
);
}
#[tokio::test]
async fn shell_command_handler_rejects_justification_without_sandbox_permissions() {
let (session, turn_context) = make_session_and_context().await;
let turn_environment = turn_context
.environments
.primary()
.expect("primary environment");
let cwd = turn_environment
.cwd()
.to_abs_path()
.expect("native environment cwd");
let params = ShellCommandToolCallParams {
command: "echo hello".to_string(),
workdir: None,
login: None,
timeout_ms: None,
sandbox_permissions: None,
additional_permissions: None,
prefix_rule: None,
justification: Some("Allow this command".to_string()),
};
let err = ShellCommandHandler::to_exec_params(
&params,
&session,
&turn_context,
turn_environment,
cwd,
/*allow_login_shell*/ false,
)
.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 =
@@ -38,7 +38,7 @@ pub(crate) struct ExecCommandArgs {
#[serde(default)]
max_output_tokens: Option<usize>,
#[serde(default)]
sandbox_permissions: SandboxPermissions,
sandbox_permissions: Option<SandboxPermissions>,
#[serde(default)]
additional_permissions: Option<AdditionalPermissionProfile>,
#[serde(default)]
@@ -13,6 +13,7 @@ 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::handlers::parse_arguments_with_base_path;
use crate::tools::handlers::resolve_sandbox_permissions;
use crate::tools::handlers::resolve_tool_environment;
use crate::tools::handlers::rewrite_function_string_argument;
use crate::tools::handlers::updated_hook_command;
@@ -188,6 +189,8 @@ impl ExecCommandHandler {
parse_arguments(&arguments)?
}
};
let sandbox_permissions =
resolve_sandbox_permissions(args.sandbox_permissions, args.justification.as_deref())?;
let hook_command = args.cmd.clone();
// TODO(anp) wire PathUri through implicit skills instead of skipping on foreign paths
if let Some(native_cwd) = native_cwd.as_ref() {
@@ -243,7 +246,7 @@ impl ExecCommandHandler {
tty,
yield_time_ms,
max_output_tokens,
sandbox_permissions,
sandbox_permissions: _,
additional_permissions,
justification,
prefix_rule,