mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Review terminal input against retained permissions (#41328)
## Why An existing terminal keeps the sandbox, network settings, and grants it had at launch. Later `write_stdin` calls must account for that retained authority, including when permissions change between turns. ## What changed - Capture each terminal's effective launch permissions and compare them with the current environment before accepting non-empty input. - Request approval when the terminal bypassed the sandbox, retains additional grants, strict auto-review is active, or its sandbox or network settings differ from the current policy. - Reject input when current denied-read or environment-owned network restrictions cannot be enforced by the existing terminal, while keeping empty polls and non-TTY interrupts available without review. - Include the effective sandbox mode and user-visible additional grants in `write_stdin` approval actions without exposing host-owned grant paths. ## Testing Added unit and end-to-end coverage for retained cross-turn grants, strict auto-review, sandbox and network policy changes, denied reads, Windows sandbox backends, and private grant redaction. GitOrigin-RevId: e2d71daf9358474c885998786c4928069100711c
This commit is contained in:
@@ -1879,6 +1879,8 @@ Order of messages:
|
||||
|
||||
When stdin approvals are enabled, a `write_stdin` approval sets `kind: "writeStdin"`, references the original terminal command's `itemId`, and has its own `approvalId`. The request belongs to the current turn, which may differ from the turn that opened the terminal. With `approvalsReviewer: "auto_review"`, the `item/autoApprovalReview/*` notifications likewise target the original command item and carry an action of type `writeStdin` with `approvalId`, `processId`, `stdin`, and `cwd`. For stdin approvals, `cwd` is the terminal’s launch directory, not its current working directory. Approving or denying a stdin write does not start, complete, or change the status of the parent command-execution item.
|
||||
|
||||
Non-empty input is reviewed when strict auto-review is active, the terminal bypassed the sandbox at launch, or its retained permissions differ from the current environment's policy, including additional grants and permission changes between turns. Changing permission settings does not re-sandbox or stop existing processes. Input is rejected when the original environment is unavailable, the retained filesystem sandbox cannot enforce current denied-read restrictions, or environment-owned network restrictions changed; empty output polls and non-TTY interrupts remain available without review. Approval reasons describe retained authority and user-visible grants even for clients that do not receive the experimental `additionalPermissions` field. Internal grant paths remain private.
|
||||
|
||||
For reviewed stdin, the complete formatted action and approval reason must fit within 8,000 bytes. Oversized or truncated actions are rejected before any bytes reach the terminal, rather than reviewing a shortened input and executing the full input.
|
||||
|
||||
### File change approvals
|
||||
|
||||
@@ -34,6 +34,8 @@ pub(crate) enum GuardianApprovalRequest {
|
||||
input: String,
|
||||
cwd: PathUri,
|
||||
tty: bool,
|
||||
sandbox_permissions: crate::sandboxing::SandboxPermissions,
|
||||
additional_permissions: Option<AdditionalPermissionProfile>,
|
||||
},
|
||||
#[cfg(unix)]
|
||||
Execve {
|
||||
@@ -128,6 +130,8 @@ struct WriteStdinApprovalAction<'a> {
|
||||
chars: &'a str,
|
||||
cwd: LegacyAppPathString,
|
||||
sandbox_permissions: crate::sandboxing::SandboxPermissions,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
additional_permissions: Option<&'a AdditionalPermissionProfile>,
|
||||
tty: bool,
|
||||
}
|
||||
|
||||
@@ -300,6 +304,8 @@ pub(crate) fn guardian_approval_request_to_json(
|
||||
input,
|
||||
cwd,
|
||||
tty,
|
||||
sandbox_permissions,
|
||||
additional_permissions,
|
||||
..
|
||||
} => serialize_guardian_action(WriteStdinApprovalAction {
|
||||
tool: "write_stdin",
|
||||
@@ -307,7 +313,8 @@ pub(crate) fn guardian_approval_request_to_json(
|
||||
session_id: *process_id,
|
||||
chars: input,
|
||||
cwd: cwd.clone().into(),
|
||||
sandbox_permissions: crate::sandboxing::SandboxPermissions::RequireEscalated,
|
||||
sandbox_permissions: *sandbox_permissions,
|
||||
additional_permissions: additional_permissions.as_ref(),
|
||||
tty: *tty,
|
||||
}),
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -45,6 +45,7 @@ use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::models::SandboxPermissions;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
@@ -1364,10 +1365,22 @@ async fn build_guardian_prompt_items_explains_network_access_review_scope() -> a
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_write_stdin_preserves_input_and_foreign_cwd() -> serde_json::Result<()> {
|
||||
#[test_case::test_case(SandboxPermissions::UseDefault)]
|
||||
#[test_case::test_case(SandboxPermissions::RequireEscalated)]
|
||||
#[test_case::test_case(SandboxPermissions::WithAdditionalPermissions)]
|
||||
fn guardian_write_stdin_preserves_input_and_foreign_cwd(
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
) -> serde_json::Result<()> {
|
||||
let cwd = PathUri::parse("file:///C:/workspace").expect("valid executor cwd");
|
||||
let input = "confirm\n";
|
||||
let additional_permissions =
|
||||
if sandbox_permissions == SandboxPermissions::WithAdditionalPermissions {
|
||||
Some(serde_json::from_value(
|
||||
serde_json::json!({"network":{"enabled":true}}),
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let action = GuardianApprovalRequest::WriteStdin {
|
||||
id: "terminal-open".to_string(),
|
||||
approval_id: "terminal-write".to_string(),
|
||||
@@ -1376,20 +1389,23 @@ fn guardian_write_stdin_preserves_input_and_foreign_cwd() -> serde_json::Result<
|
||||
input: input.to_string(),
|
||||
cwd: cwd.clone(),
|
||||
tty: true,
|
||||
sandbox_permissions,
|
||||
additional_permissions: additional_permissions.clone(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
guardian_approval_request_to_json(&action)?,
|
||||
serde_json::json!({
|
||||
"tool": "write_stdin",
|
||||
"environment_id": "windows-executor",
|
||||
"session_id": 1000,
|
||||
"chars": input,
|
||||
"cwd": r"C:\workspace",
|
||||
"sandbox_permissions": "require_escalated",
|
||||
"tty": true,
|
||||
}),
|
||||
);
|
||||
let mut expected = serde_json::json!({
|
||||
"tool": "write_stdin",
|
||||
"environment_id": "windows-executor",
|
||||
"session_id": 1000,
|
||||
"chars": input,
|
||||
"cwd": r"C:\workspace",
|
||||
"sandbox_permissions": sandbox_permissions,
|
||||
"tty": true,
|
||||
});
|
||||
if let Some(permissions) = additional_permissions {
|
||||
expected["additional_permissions"] = serde_json::to_value(permissions)?;
|
||||
}
|
||||
assert_eq!(guardian_approval_request_to_json(&action)?, expected);
|
||||
assert_eq!(
|
||||
guardian_assessment_action(&action),
|
||||
GuardianAssessmentAction::WriteStdin {
|
||||
|
||||
@@ -85,6 +85,8 @@ pub(crate) enum ApprovalAction {
|
||||
input: String,
|
||||
cwd: PathUri,
|
||||
tty: bool,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
additional_permissions: Option<AdditionalPermissionProfile>,
|
||||
},
|
||||
#[cfg(unix)]
|
||||
Execve {
|
||||
@@ -171,6 +173,8 @@ impl ApprovalAction {
|
||||
input,
|
||||
cwd,
|
||||
tty,
|
||||
sandbox_permissions,
|
||||
additional_permissions,
|
||||
} => PermissionRequestPayload {
|
||||
tool_name: HookToolName::new("write_stdin"),
|
||||
tool_input: serde_json::json!({
|
||||
@@ -181,6 +185,8 @@ impl ApprovalAction {
|
||||
"environment_id": environment_id,
|
||||
"cwd": cwd,
|
||||
"tty": tty,
|
||||
"sandbox_permissions": sandbox_permissions,
|
||||
"additional_permissions": additional_permissions,
|
||||
}),
|
||||
},
|
||||
#[cfg(unix)]
|
||||
@@ -297,6 +303,8 @@ impl ApprovalAction {
|
||||
input,
|
||||
cwd,
|
||||
tty,
|
||||
sandbox_permissions,
|
||||
additional_permissions,
|
||||
} => crate::guardian::GuardianApprovalRequest::WriteStdin {
|
||||
id,
|
||||
approval_id,
|
||||
@@ -305,6 +313,8 @@ impl ApprovalAction {
|
||||
input,
|
||||
cwd,
|
||||
tty,
|
||||
sandbox_permissions,
|
||||
additional_permissions,
|
||||
},
|
||||
#[cfg(unix)]
|
||||
Self::Execve {
|
||||
@@ -487,10 +497,11 @@ impl Session {
|
||||
action: ApprovalAction,
|
||||
ctx: ApprovalContext,
|
||||
) -> Result<ReviewDecision, ToolError> {
|
||||
// Stdin is a fresh sandbox approval, not an exec-policy cache hit. Strict
|
||||
// review can route Never to Guardian, but granular restrictions still apply.
|
||||
// Stdin that exceeds current permissions needs a fresh sandbox approval.
|
||||
// Strict review of ordinary input follows the same routing as ordinary exec.
|
||||
let policy = ctx.review_context.turn().approval_policy();
|
||||
if matches!(&action, ApprovalAction::WriteStdin { .. })
|
||||
if matches!(&action, ApprovalAction::WriteStdin { sandbox_permissions, .. }
|
||||
if sandbox_permissions.requests_sandbox_override())
|
||||
&& !(ctx.strict_auto_review && matches!(policy, AskForApproval::Never))
|
||||
&& let Some(reason) =
|
||||
prompt_is_rejected_by_policy(policy, /*prompt_is_rule*/ false)
|
||||
@@ -751,6 +762,7 @@ impl Session {
|
||||
process_id,
|
||||
input,
|
||||
cwd,
|
||||
additional_permissions,
|
||||
..
|
||||
} => {
|
||||
self.request_command_approval(
|
||||
@@ -769,7 +781,7 @@ impl Session {
|
||||
ctx.approval_reason.clone(),
|
||||
/*network_approval_context*/ None,
|
||||
/*proposed_execpolicy_amendment*/ None,
|
||||
/*additional_permissions*/ None,
|
||||
additional_permissions.clone(),
|
||||
Some(vec![ReviewDecision::Approved, ReviewDecision::Abort]),
|
||||
/*plugin_attribution_override*/ None,
|
||||
)
|
||||
|
||||
@@ -35,6 +35,8 @@ use crate::tools::sandboxing::ToolRuntime;
|
||||
use crate::tools::sandboxing::managed_network_for_sandbox_permissions;
|
||||
use crate::tools::sandboxing::sandbox_permissions_preserving_denied_reads;
|
||||
use crate::unified_exec::NoopSpawnLifecycle;
|
||||
use crate::unified_exec::TerminalPermissions;
|
||||
use crate::unified_exec::TerminalSandboxSource;
|
||||
use crate::unified_exec::UnifiedExecError;
|
||||
use crate::unified_exec::UnifiedExecProcess;
|
||||
use crate::unified_exec::UnifiedExecProcessManager;
|
||||
@@ -106,7 +108,7 @@ pub struct UnifiedExecRuntime<'a> {
|
||||
pub(crate) struct UnifiedExecAttempt {
|
||||
pub(crate) process: UnifiedExecProcess,
|
||||
pub(crate) metrics_sidecar: Option<PluginMetricsSidecar>,
|
||||
pub(crate) escalated: bool,
|
||||
pub(crate) permissions: TerminalPermissions,
|
||||
}
|
||||
|
||||
fn unified_exec_options(
|
||||
@@ -418,6 +420,22 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
|
||||
req.additional_permissions.as_ref(),
|
||||
sidecar_permissions.as_ref(),
|
||||
);
|
||||
let permissions = TerminalPermissions::for_launch(
|
||||
&req.turn_environment,
|
||||
&ctx.step_context.turn,
|
||||
if self.uses_executor_managed_process_sandbox(req) {
|
||||
TerminalSandboxSource::Executor
|
||||
} else {
|
||||
TerminalSandboxSource::Native
|
||||
},
|
||||
if attempt.is_escalated() {
|
||||
SandboxPermissions::RequireEscalated
|
||||
} else {
|
||||
SandboxPermissions::UseDefault
|
||||
},
|
||||
req.additional_permissions.as_ref(),
|
||||
sidecar_permissions.as_ref(),
|
||||
);
|
||||
|
||||
if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode {
|
||||
let command = build_unified_exec_sandbox_command(
|
||||
@@ -477,7 +495,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
|
||||
return Ok(UnifiedExecAttempt {
|
||||
process,
|
||||
metrics_sidecar,
|
||||
escalated: attempt.is_escalated(),
|
||||
permissions,
|
||||
});
|
||||
}
|
||||
None => {
|
||||
@@ -522,7 +540,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
|
||||
Ok(UnifiedExecAttempt {
|
||||
process,
|
||||
metrics_sidecar,
|
||||
escalated: attempt.is_escalated(),
|
||||
permissions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ mod process;
|
||||
mod process_manager;
|
||||
mod process_state;
|
||||
mod shell_snapshot;
|
||||
mod stdin_approval;
|
||||
|
||||
pub(crate) fn set_deterministic_process_ids_for_tests(enabled: bool) {
|
||||
process_manager::set_deterministic_process_ids_for_tests(enabled);
|
||||
@@ -64,6 +65,8 @@ pub(crate) use process::NoopSpawnLifecycle;
|
||||
pub(crate) use process::SpawnLifecycle;
|
||||
pub(crate) use process::SpawnLifecycleHandle;
|
||||
pub(crate) use process::UnifiedExecProcess;
|
||||
pub(crate) use stdin_approval::TerminalPermissions;
|
||||
pub(crate) use stdin_approval::TerminalSandboxSource;
|
||||
|
||||
pub(crate) const MIN_YIELD_TIME_MS: u64 = 250;
|
||||
pub(crate) const WINDOWS_INITIAL_EXEC_YIELD_TIME_FLOOR_MS: u64 = 10_000;
|
||||
@@ -185,9 +188,7 @@ struct ProcessEntry {
|
||||
hook_command: String,
|
||||
tty: bool,
|
||||
environment_id: String,
|
||||
// The successful launch bypassed sandboxing required by its ambient policy.
|
||||
// Preserve this across turns so subsequent stdin writes can require approval.
|
||||
escalated: bool,
|
||||
permissions: TerminalPermissions,
|
||||
network_approval: Option<DeferredNetworkApproval>,
|
||||
session: Weak<Session>,
|
||||
last_used: tokio::time::Instant,
|
||||
|
||||
@@ -142,7 +142,14 @@ async fn exec_command_with_tty(
|
||||
hook_command: cmd.to_string(),
|
||||
tty,
|
||||
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
||||
escalated: false,
|
||||
permissions: TerminalPermissions::for_launch(
|
||||
turn.environments.primary().expect("turn environment"),
|
||||
turn,
|
||||
TerminalSandboxSource::Native,
|
||||
SandboxPermissions::UseDefault,
|
||||
/*additional_permissions*/ None,
|
||||
/*internal_permissions*/ None,
|
||||
),
|
||||
network_approval: None,
|
||||
session: Arc::downgrade(session),
|
||||
last_used: started_at,
|
||||
@@ -609,7 +616,14 @@ async fn terminating_initial_exec_command_rechecks_initial_response_state() -> a
|
||||
hook_command: "sleep 60".to_string(),
|
||||
tty: true,
|
||||
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
||||
escalated: false,
|
||||
permissions: TerminalPermissions::for_launch(
|
||||
turn.environments.primary().expect("turn environment"),
|
||||
&turn,
|
||||
TerminalSandboxSource::Native,
|
||||
SandboxPermissions::UseDefault,
|
||||
/*additional_permissions*/ None,
|
||||
/*internal_permissions*/ None,
|
||||
),
|
||||
network_approval: None,
|
||||
session: Arc::downgrade(&session),
|
||||
last_used: Instant::now(),
|
||||
@@ -685,7 +699,14 @@ async fn terminating_during_stdin_poll_returns_exited_response() -> anyhow::Resu
|
||||
hook_command: "sleep 60".to_string(),
|
||||
tty: true,
|
||||
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
||||
escalated: false,
|
||||
permissions: TerminalPermissions::for_launch(
|
||||
turn.environments.primary().expect("turn environment"),
|
||||
&turn,
|
||||
TerminalSandboxSource::Native,
|
||||
SandboxPermissions::UseDefault,
|
||||
/*additional_permissions*/ None,
|
||||
/*internal_permissions*/ None,
|
||||
),
|
||||
network_approval: None,
|
||||
session: Arc::downgrade(&session),
|
||||
last_used,
|
||||
@@ -899,11 +920,32 @@ async fn stdin_approval_preserves_the_reviewed_terminal() -> anyhow::Result<()>
|
||||
let original = {
|
||||
let mut store = manager.process_store.lock().await;
|
||||
let entry = store.processes.get_mut(&process_id).unwrap();
|
||||
entry.escalated = true;
|
||||
entry.permissions = TerminalPermissions::for_launch(
|
||||
turn.environments.primary().expect("turn environment"),
|
||||
&turn,
|
||||
TerminalSandboxSource::Native,
|
||||
SandboxPermissions::RequireEscalated,
|
||||
/*additional_permissions*/ None,
|
||||
/*internal_permissions*/ None,
|
||||
);
|
||||
entry.environment_id = "unselected-executor".to_string();
|
||||
entry.cwd = cwd.clone();
|
||||
Arc::clone(&entry.process)
|
||||
};
|
||||
// A queued write must acquire the terminal lock before reading active strict
|
||||
// mode: code-mode calls can enable it while another interaction is draining.
|
||||
{
|
||||
let interaction = original.interaction_lock().lock_owned().await;
|
||||
let _active_turn = session.active_turn.lock().await;
|
||||
let mut queued = Box::pin(write_stdin(
|
||||
&session, &turn, process_id, "queued\n", /*yield_time_ms*/ 250,
|
||||
));
|
||||
let mut task_context = std::task::Context::from_waker(futures::task::noop_waker_ref());
|
||||
assert!(queued.as_mut().poll(&mut task_context).is_pending());
|
||||
drop(interaction);
|
||||
assert!(queued.as_mut().poll(&mut task_context).is_pending());
|
||||
assert!(original.interaction_lock().try_lock_owned().is_err());
|
||||
}
|
||||
// Empty polling must complete without an approval response.
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(/*secs*/ 5),
|
||||
@@ -921,6 +963,25 @@ async fn stdin_approval_preserves_the_reviewed_terminal() -> anyhow::Result<()>
|
||||
);
|
||||
Arc::make_mut(&mut Arc::get_mut(&mut turn).unwrap().config).approvals_reviewer =
|
||||
ApprovalsReviewer::User;
|
||||
assert!(matches!(
|
||||
write_stdin(&session, &turn, process_id, input, /*yield_time_ms*/ 250).await,
|
||||
Err(UnifiedExecError::StdinApproval(ToolError::Rejected(reason)))
|
||||
if reason.contains("select it before retrying")
|
||||
));
|
||||
manager
|
||||
.process_store
|
||||
.lock()
|
||||
.await
|
||||
.processes
|
||||
.get_mut(&process_id)
|
||||
.unwrap()
|
||||
.environment_id = turn
|
||||
.environments
|
||||
.primary()
|
||||
.unwrap()
|
||||
.selection
|
||||
.environment_id
|
||||
.clone();
|
||||
for (input, decision) in [
|
||||
("rejected\n", ReviewDecision::denied("test denial")),
|
||||
("accepted\n", ReviewDecision::Approved),
|
||||
|
||||
@@ -37,7 +37,6 @@ use crate::tools::runtimes::is_managed_proxy_env_var;
|
||||
use crate::tools::runtimes::unified_exec::UnifiedExecAttempt;
|
||||
use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest;
|
||||
use crate::tools::runtimes::unified_exec::UnifiedExecRuntime;
|
||||
use crate::tools::sandboxing::ApprovalAction;
|
||||
use crate::tools::sandboxing::SandboxAttempt;
|
||||
use crate::tools::sandboxing::ToolCtx;
|
||||
use crate::tools::sandboxing::ToolError;
|
||||
@@ -69,7 +68,6 @@ use codex_core_plugins::PLUGIN_METRICS_OUTPUT_ENV_VAR;
|
||||
use codex_core_plugins::PluginCommandAttribution;
|
||||
use codex_core_plugins::PluginMetricsSidecar;
|
||||
use codex_core_plugins::strip_output_env;
|
||||
use codex_features::Feature;
|
||||
use codex_network_proxy::NetworkPolicyDecider;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::config_types::ShellEnvironmentPolicy;
|
||||
@@ -489,7 +487,7 @@ impl UnifiedExecProcessManager {
|
||||
let UnifiedExecAttempt {
|
||||
process,
|
||||
metrics_sidecar,
|
||||
escalated,
|
||||
permissions,
|
||||
} = attempt;
|
||||
let process = Arc::new(process);
|
||||
let network_denial_monitor = deferred_network_approval.as_ref().map(|deferred| {
|
||||
@@ -549,7 +547,7 @@ impl UnifiedExecProcessManager {
|
||||
request.hook_command.clone(),
|
||||
cwd.clone(),
|
||||
request.turn_environment.selection.environment_id.clone(),
|
||||
escalated,
|
||||
permissions,
|
||||
plugin_attribution.clone(),
|
||||
start,
|
||||
request.process_id,
|
||||
@@ -772,33 +770,31 @@ impl UnifiedExecProcessManager {
|
||||
// Different terminal sessions can be polled concurrently, but reads and
|
||||
// writes against one terminal must not overlap because they share a
|
||||
// draining output buffer and process lifecycle.
|
||||
let (locked_process, approval) = {
|
||||
let locked_process = {
|
||||
let store = self.process_store.lock().await;
|
||||
let entry = store
|
||||
.processes
|
||||
.get(&process_id)
|
||||
.ok_or(UnifiedExecError::UnknownProcessId { process_id })?;
|
||||
let approval = (!request.input.is_empty()
|
||||
&& (entry.tty || request.input != INTERRUPT)
|
||||
&& entry.escalated
|
||||
&& context
|
||||
.session
|
||||
.features()
|
||||
.enabled(Feature::WriteStdinApproval))
|
||||
.then(|| ApprovalAction::WriteStdin {
|
||||
id: entry.call_id.clone(),
|
||||
approval_id: context.call_id.clone(),
|
||||
environment_id: entry.environment_id.clone(),
|
||||
process_id,
|
||||
input: request.input.to_string(),
|
||||
cwd: entry.cwd.clone(),
|
||||
tty: entry.tty,
|
||||
});
|
||||
(Arc::clone(&entry.process), approval)
|
||||
Arc::clone(&entry.process)
|
||||
};
|
||||
let _interaction_guard = locked_process.interaction_lock().lock_owned().await;
|
||||
if let Some(approval) = approval {
|
||||
let approval_reason = "Send input to an existing escalated terminal. The cwd is its launch directory; the terminal's current directory and state may have changed.".to_string();
|
||||
// A queued write must observe strict review enabled while it was waiting.
|
||||
let strict_auto_review = context
|
||||
.session
|
||||
.active_turn_context_and_strict_auto_review()
|
||||
.await
|
||||
.is_some_and(|(_, _, strict)| strict);
|
||||
let approval = {
|
||||
let store = self.process_store.lock().await;
|
||||
let entry = store
|
||||
.processes
|
||||
.get(&process_id)
|
||||
.filter(|entry| Arc::ptr_eq(&entry.process, &locked_process))
|
||||
.ok_or(UnifiedExecError::UnknownProcessId { process_id })?;
|
||||
entry.stdin_approval(context, request.input, strict_auto_review)?
|
||||
};
|
||||
if let Some((approval, approval_reason)) = approval {
|
||||
let reviewed = crate::guardian::format_guardian_action_pretty(
|
||||
&approval.clone().into_guardian_request().map_err(|err| {
|
||||
UnifiedExecError::StdinApproval(ToolError::Rejected(err.to_string()))
|
||||
@@ -830,11 +826,6 @@ impl UnifiedExecProcessManager {
|
||||
"terminal input and permission details are too large to review safely; use a smaller input or start a new terminal with fewer grants".to_string(),
|
||||
)));
|
||||
}
|
||||
let strict_auto_review = context
|
||||
.session
|
||||
.active_turn_context_and_strict_auto_review()
|
||||
.await
|
||||
.is_some_and(|(_, _, strict)| strict);
|
||||
let approval_context = ApprovalContext {
|
||||
review_context: GuardianReviewContext::from(&context.step_context),
|
||||
cancellation_token: Some(context.cancellation_token.clone()),
|
||||
@@ -1083,7 +1074,7 @@ impl UnifiedExecProcessManager {
|
||||
hook_command: String,
|
||||
cwd: PathUri,
|
||||
environment_id: String,
|
||||
escalated: bool,
|
||||
permissions: super::TerminalPermissions,
|
||||
plugin_attribution: Option<PluginCommandAttribution>,
|
||||
started_at: Instant,
|
||||
process_id: i32,
|
||||
@@ -1106,7 +1097,7 @@ impl UnifiedExecProcessManager {
|
||||
hook_command,
|
||||
tty,
|
||||
environment_id,
|
||||
escalated,
|
||||
permissions,
|
||||
network_approval,
|
||||
session: Arc::downgrade(&context.session),
|
||||
last_used: started_at,
|
||||
|
||||
@@ -545,6 +545,7 @@ fn pruning_protects_recent_processes_even_if_exited() {
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn pruning_does_not_evict_live_process_while_exited_process_is_finalizing() {
|
||||
let (_, turn) = crate::session::tests::make_session_and_context().await;
|
||||
let exited_process = Arc::new(
|
||||
crate::unified_exec::process_tests::remote_process(
|
||||
codex_exec_server::WriteStatus::Accepted,
|
||||
@@ -590,7 +591,14 @@ async fn pruning_does_not_evict_live_process_while_exited_process_is_finalizing(
|
||||
hook_command: format!("command-{process_id}"),
|
||||
tty: false,
|
||||
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
||||
escalated: false,
|
||||
permissions: super::super::TerminalPermissions::for_launch(
|
||||
turn.environments.primary().expect("turn environment"),
|
||||
&turn,
|
||||
super::super::TerminalSandboxSource::Native,
|
||||
crate::sandboxing::SandboxPermissions::UseDefault,
|
||||
/*additional_permissions*/ None,
|
||||
/*internal_permissions*/ None,
|
||||
),
|
||||
network_approval: None,
|
||||
session: std::sync::Weak::new(),
|
||||
last_used: if is_exited {
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: core/src/unified_exec/stdin_approval_tests.rs
|
||||
expression: reason
|
||||
---
|
||||
Send input to an existing terminal. This terminal retains additional permissions. It also has an internal plugin metrics write grant. The cwd is its launch directory; the terminal's current directory and state may have changed.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: core/src/unified_exec/stdin_approval_tests.rs
|
||||
expression: reason
|
||||
---
|
||||
Send input to an existing terminal. This terminal retains sandbox or network settings that differ from the current permissions. The cwd is its launch directory; the terminal's current directory and state may have changed.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: core/src/unified_exec/stdin_approval_tests.rs
|
||||
expression: reason
|
||||
---
|
||||
Send input to an existing terminal. This terminal was launched outside the sandbox, bypassing any managed network proxy. The cwd is its launch directory; the terminal's current directory and state may have changed.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: core/src/unified_exec/stdin_approval_tests.rs
|
||||
expression: permissions.approval_reason(expected)?
|
||||
---
|
||||
Send input to an existing terminal. This terminal runs without a filesystem sandbox. The cwd is its launch directory; the terminal's current directory and state may have changed.
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Reviews terminal input against retained launch permissions and current policy.
|
||||
//! Permission snapshots stay host-owned; neither an earlier approval nor a policy
|
||||
//! change alters the sandbox of an already-running process. Native launches keep
|
||||
//! their configured Windows sandbox level; executor launches use executor defaults.
|
||||
|
||||
use super::ProcessEntry;
|
||||
use super::UnifiedExecContext;
|
||||
use super::UnifiedExecError;
|
||||
use crate::config::NetworkProxySpec;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::session::turn_context::TurnEnvironment;
|
||||
use crate::tools::sandboxing::ApprovalAction;
|
||||
use crate::tools::sandboxing::ToolError;
|
||||
use codex_features::Feature;
|
||||
use codex_file_system::ExecPermissionProfile;
|
||||
use codex_file_system::FileSystemSandboxContext;
|
||||
use codex_network_proxy::EnvironmentNetworkPolicy;
|
||||
use codex_protocol::models::AdditionalPermissionProfile;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::SandboxPermissions;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_sandboxing::policy_transforms::effective_permission_profile;
|
||||
use codex_sandboxing::policy_transforms::merge_permission_profiles;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum TerminalSandboxSource {
|
||||
Native,
|
||||
Executor,
|
||||
}
|
||||
|
||||
pub(crate) struct TerminalPermissions {
|
||||
policy: TerminalPolicy,
|
||||
sandbox_source: TerminalSandboxSource,
|
||||
launch_permissions: SandboxPermissions,
|
||||
additional_permissions: Option<AdditionalPermissionProfile>,
|
||||
internal_permissions: Option<AdditionalPermissionProfile>,
|
||||
}
|
||||
|
||||
/// Host-owned launch settings. Never serialize these into approval messages.
|
||||
#[derive(PartialEq, Eq)]
|
||||
struct TerminalPolicy {
|
||||
sandbox: FileSystemSandboxContext,
|
||||
environment_network: Option<EnvironmentNetworkPolicy>,
|
||||
controller_network: Option<NetworkProxySpec>,
|
||||
controller_proxy: bool,
|
||||
}
|
||||
|
||||
impl TerminalPolicy {
|
||||
fn capture(
|
||||
environment: &TurnEnvironment,
|
||||
turn: &TurnContext,
|
||||
sandbox_source: TerminalSandboxSource,
|
||||
additional_permissions: Option<AdditionalPermissionProfile>,
|
||||
) -> Self {
|
||||
let mut sandbox = environment.sandbox_context(additional_permissions);
|
||||
if matches!(sandbox_source, TerminalSandboxSource::Native) {
|
||||
// The filesystem helper applies executor defaults, but native process
|
||||
// launches honor Disabled. Preserve it so later enablement is detected.
|
||||
sandbox.windows_sandbox_level = environment.config().windows_sandbox_level;
|
||||
}
|
||||
Self {
|
||||
sandbox,
|
||||
environment_network: environment.config().network_policy.clone(),
|
||||
controller_network: turn.config.permissions.network.clone(),
|
||||
controller_proxy: turn.network.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
fn file_system_context(&self) -> FileSystemSandboxContext {
|
||||
let mut context = self.sandbox.clone();
|
||||
// Network changes require review, not rejection for denied-read drift.
|
||||
if let ExecPermissionProfile::Managed { network, .. } = &mut context.permissions {
|
||||
*network = NetworkSandboxPolicy::Restricted;
|
||||
}
|
||||
context
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalPermissions {
|
||||
pub(crate) fn for_launch(
|
||||
environment: &TurnEnvironment,
|
||||
turn: &TurnContext,
|
||||
sandbox_source: TerminalSandboxSource,
|
||||
launch_permissions: SandboxPermissions,
|
||||
additional_permissions: Option<&AdditionalPermissionProfile>,
|
||||
internal_permissions: Option<&AdditionalPermissionProfile>,
|
||||
) -> Self {
|
||||
Self {
|
||||
policy: TerminalPolicy::capture(
|
||||
environment,
|
||||
turn,
|
||||
sandbox_source,
|
||||
merge_permission_profiles(additional_permissions, internal_permissions),
|
||||
),
|
||||
sandbox_source,
|
||||
// A bypass is a property of the successful attempt, not a difference
|
||||
// between settings: a full-access environment can still bypass a proxy.
|
||||
launch_permissions,
|
||||
additional_permissions: additional_permissions.cloned(),
|
||||
internal_permissions: internal_permissions.cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares launch policy with current policy including retained grants,
|
||||
/// without reading turn state or touching a process.
|
||||
fn review_requirement(
|
||||
&self,
|
||||
current: &TerminalPolicy,
|
||||
baseline: &PermissionProfile,
|
||||
) -> Result<SandboxPermissions, &'static str> {
|
||||
let bypassed = self.launch_permissions.requires_escalated_permissions();
|
||||
if current.environment_network.is_some()
|
||||
&& (bypassed || self.policy.environment_network != current.environment_network)
|
||||
{
|
||||
return Err(
|
||||
"this terminal cannot enforce the current environment-owned network restrictions; start a new terminal",
|
||||
);
|
||||
}
|
||||
// Approval cannot retrofit denied reads onto a running process. Unless
|
||||
// its sandbox still matches, start a new terminal under the current policy.
|
||||
if baseline
|
||||
.file_system_sandbox_policy()
|
||||
.has_denied_read_restrictions()
|
||||
&& (bypassed || self.policy.file_system_context() != current.file_system_context())
|
||||
{
|
||||
return Err(
|
||||
"this terminal cannot enforce the current denied-read restrictions; start a new terminal",
|
||||
);
|
||||
}
|
||||
// Once the retained settings match, only the baseline permissions can differ.
|
||||
Ok(if bypassed || &self.policy != current {
|
||||
SandboxPermissions::RequireEscalated
|
||||
} else if self.policy.sandbox.permissions
|
||||
== effective_permission_profile(baseline, /*additional_permissions*/ None).into()
|
||||
{
|
||||
SandboxPermissions::UseDefault
|
||||
} else {
|
||||
SandboxPermissions::WithAdditionalPermissions
|
||||
})
|
||||
}
|
||||
|
||||
fn approval_reason(
|
||||
&self,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
) -> Result<String, serde_json::Error> {
|
||||
let authority = if self.launch_permissions.requires_escalated_permissions() {
|
||||
"This terminal was launched outside the sandbox, bypassing any managed network proxy."
|
||||
} else if self.policy.sandbox.permissions == ExecPermissionProfile::Disabled {
|
||||
"This terminal runs without a filesystem sandbox."
|
||||
} else {
|
||||
match sandbox_permissions {
|
||||
SandboxPermissions::UseDefault => "This terminal uses the current permissions.",
|
||||
SandboxPermissions::WithAdditionalPermissions => {
|
||||
"This terminal retains additional permissions."
|
||||
}
|
||||
SandboxPermissions::RequireEscalated => {
|
||||
"This terminal retains sandbox or network settings that differ from the current permissions."
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut reason = format!("Send input to an existing terminal. {authority}");
|
||||
if self.internal_permissions.is_some() {
|
||||
reason.push_str(" It also has an internal plugin metrics write grant.");
|
||||
}
|
||||
reason.push_str(" The cwd is its launch directory; the terminal's current directory and state may have changed.");
|
||||
if let Some(grants) = &self.additional_permissions {
|
||||
// Stable reason text also reaches clients that strip the experimental
|
||||
// additionalPermissions field. Internal paths never enter this text.
|
||||
reason.push_str(&format!(
|
||||
" Retained grants: {}.",
|
||||
serde_json::to_string(grants)?
|
||||
));
|
||||
}
|
||||
Ok(reason)
|
||||
}
|
||||
}
|
||||
|
||||
impl ProcessEntry {
|
||||
pub(super) fn stdin_approval(
|
||||
&self,
|
||||
context: &UnifiedExecContext,
|
||||
input: &str,
|
||||
strict_auto_review: bool,
|
||||
) -> Result<Option<(ApprovalAction, String)>, UnifiedExecError> {
|
||||
if input.is_empty()
|
||||
|| (!self.tty && input == "\u{3}")
|
||||
|| !context
|
||||
.session
|
||||
.features()
|
||||
.enabled(Feature::WriteStdinApproval)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let environment = context
|
||||
.step_context
|
||||
.environments
|
||||
.turn_environments()
|
||||
.find(|environment| environment.selection.environment_id == self.environment_id)
|
||||
.ok_or_else(|| {
|
||||
approval_error(
|
||||
"cannot access the terminal's original environment; select it before retrying",
|
||||
)
|
||||
})?;
|
||||
let permissions = &self.permissions;
|
||||
let current = TerminalPolicy::capture(
|
||||
environment,
|
||||
&context.step_context.turn,
|
||||
permissions.sandbox_source,
|
||||
merge_permission_profiles(
|
||||
permissions.additional_permissions.as_ref(),
|
||||
permissions.internal_permissions.as_ref(),
|
||||
),
|
||||
);
|
||||
let sandbox_permissions = permissions
|
||||
.review_requirement(¤t, environment.permission_profile())
|
||||
.map_err(approval_error)?;
|
||||
if sandbox_permissions == SandboxPermissions::UseDefault && !strict_auto_review {
|
||||
return Ok(None);
|
||||
}
|
||||
let reason = permissions
|
||||
.approval_reason(sandbox_permissions)
|
||||
.map_err(approval_error)?;
|
||||
let action = ApprovalAction::WriteStdin {
|
||||
id: self.call_id.clone(),
|
||||
approval_id: context.call_id.clone(),
|
||||
environment_id: self.environment_id.clone(),
|
||||
process_id: self.process_id,
|
||||
input: input.to_string(),
|
||||
cwd: self.cwd.clone(),
|
||||
tty: self.tty,
|
||||
sandbox_permissions,
|
||||
additional_permissions: permissions.additional_permissions.clone(),
|
||||
};
|
||||
Ok(Some((action, reason)))
|
||||
}
|
||||
}
|
||||
|
||||
fn approval_error(reason: impl std::fmt::Display) -> UnifiedExecError {
|
||||
UnifiedExecError::StdinApproval(ToolError::Rejected(reason.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "stdin_approval_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Direct policy comparisons, launch capture, and private approval text.
|
||||
|
||||
use super::*;
|
||||
use crate::session::tests::make_session_and_context;
|
||||
use codex_network_proxy::NetworkMode;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::PermissionProfileSnapshot;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn terminal_permissions(profile: &PermissionProfile) -> TerminalPermissions {
|
||||
TerminalPermissions {
|
||||
policy: TerminalPolicy {
|
||||
sandbox: FileSystemSandboxContext::from_permission_profile(
|
||||
effective_permission_profile(profile, /*additional_permissions*/ None),
|
||||
),
|
||||
environment_network: None,
|
||||
controller_network: None,
|
||||
controller_proxy: false,
|
||||
},
|
||||
sandbox_source: TerminalSandboxSource::Native,
|
||||
launch_permissions: SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
internal_permissions: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduced_permissions_require_review() -> anyhow::Result<()> {
|
||||
let permissions = terminal_permissions(&PermissionProfile::Disabled);
|
||||
let baseline = PermissionProfile::read_only();
|
||||
let current = terminal_permissions(&baseline);
|
||||
let expected = SandboxPermissions::RequireEscalated;
|
||||
assert_eq!(
|
||||
permissions.review_requirement(¤t.policy, &baseline),
|
||||
Ok(expected)
|
||||
);
|
||||
insta::assert_snapshot!(
|
||||
"reduced_permissions",
|
||||
permissions.approval_reason(expected)?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_bypass_requires_review_even_when_permissions_match() -> anyhow::Result<()> {
|
||||
let baseline = PermissionProfile::Disabled;
|
||||
let mut permissions = terminal_permissions(&baseline);
|
||||
permissions.launch_permissions = SandboxPermissions::RequireEscalated;
|
||||
let expected = SandboxPermissions::RequireEscalated;
|
||||
assert_eq!(
|
||||
permissions.review_requirement(&permissions.policy, &baseline),
|
||||
Ok(expected)
|
||||
);
|
||||
insta::assert_snapshot!("proxy_bypass", permissions.approval_reason(expected)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denied_reads_reject_file_system_changes_but_only_review_network_changes() {
|
||||
let mut file_system = FileSystemSandboxPolicy::read_only();
|
||||
file_system.entries.push(FileSystemSandboxEntry::new(
|
||||
FileSystemPath::GlobPattern {
|
||||
pattern: "**/.env".into(),
|
||||
},
|
||||
FileSystemAccessMode::Deny,
|
||||
));
|
||||
let baseline =
|
||||
PermissionProfile::from_runtime_permissions(&file_system, NetworkSandboxPolicy::Restricted);
|
||||
let permissions = terminal_permissions(&PermissionProfile::Disabled);
|
||||
let current = terminal_permissions(&baseline);
|
||||
assert_eq!(
|
||||
permissions.review_requirement(¤t.policy, &baseline),
|
||||
Err(
|
||||
"this terminal cannot enforce the current denied-read restrictions; start a new terminal"
|
||||
)
|
||||
);
|
||||
|
||||
let permissions = current;
|
||||
let baseline =
|
||||
PermissionProfile::from_runtime_permissions(&file_system, NetworkSandboxPolicy::Enabled);
|
||||
let current = terminal_permissions(&baseline);
|
||||
assert_eq!(
|
||||
permissions.review_requirement(¤t.policy, &baseline),
|
||||
Ok(SandboxPermissions::RequireEscalated)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn captured_network_changes_require_review_or_a_new_terminal() -> anyhow::Result<()> {
|
||||
let (_session, mut turn) = make_session_and_context().await;
|
||||
let mut environment = turn.environments.primary().expect("environment").clone();
|
||||
environment.config_mut().permission_profile =
|
||||
PermissionProfileSnapshot::legacy(PermissionProfile::read_only());
|
||||
let mut proxy = NetworkProxyConfig {
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut turn.config).permissions.network =
|
||||
Some(NetworkProxySpec::from_config_and_constraints(
|
||||
proxy.clone(),
|
||||
/*requirements*/ None,
|
||||
&turn.permission_profile(),
|
||||
)?);
|
||||
let permissions = TerminalPermissions::for_launch(
|
||||
&environment,
|
||||
&turn,
|
||||
TerminalSandboxSource::Native,
|
||||
SandboxPermissions::UseDefault,
|
||||
/*additional_permissions*/ None,
|
||||
/*internal_permissions*/ None,
|
||||
);
|
||||
|
||||
proxy.mode = NetworkMode::Limited;
|
||||
Arc::make_mut(&mut turn.config).permissions.network =
|
||||
Some(NetworkProxySpec::from_config_and_constraints(
|
||||
proxy.clone(),
|
||||
/*requirements*/ None,
|
||||
&turn.permission_profile(),
|
||||
)?);
|
||||
let current = TerminalPolicy::capture(
|
||||
&environment,
|
||||
&turn,
|
||||
TerminalSandboxSource::Native,
|
||||
/*additional_permissions*/ None,
|
||||
);
|
||||
let expected = SandboxPermissions::RequireEscalated;
|
||||
assert_eq!(
|
||||
permissions.review_requirement(¤t, environment.permission_profile()),
|
||||
Ok(expected)
|
||||
);
|
||||
insta::assert_snapshot!("network_mode", permissions.approval_reason(expected)?);
|
||||
|
||||
environment.config_mut().network_policy = Some(EnvironmentNetworkPolicy::from_config(
|
||||
&proxy, /*managed_allowed_domains_only*/ false,
|
||||
));
|
||||
let current = TerminalPolicy::capture(
|
||||
&environment,
|
||||
&turn,
|
||||
TerminalSandboxSource::Native,
|
||||
/*additional_permissions*/ None,
|
||||
);
|
||||
assert_eq!(
|
||||
permissions.review_requirement(¤t, environment.permission_profile()),
|
||||
Err(
|
||||
"this terminal cannot enforce the current environment-owned network restrictions; start a new terminal"
|
||||
)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn internal_grants_require_review_without_exposing_paths() -> anyhow::Result<()> {
|
||||
let (_session, turn) = make_session_and_context().await;
|
||||
let mut environment = turn.environments.primary().expect("environment").clone();
|
||||
environment.config_mut().permission_profile =
|
||||
PermissionProfileSnapshot::legacy(PermissionProfile::read_only());
|
||||
let grants = serde_json::from_value(json!({
|
||||
"file_system": {"write": [turn.config.cwd.join("private-metrics")]}
|
||||
}))?;
|
||||
let permissions = TerminalPermissions::for_launch(
|
||||
&environment,
|
||||
&turn,
|
||||
TerminalSandboxSource::Native,
|
||||
SandboxPermissions::UseDefault,
|
||||
/*additional_permissions*/ None,
|
||||
Some(&grants),
|
||||
);
|
||||
let current = TerminalPolicy::capture(
|
||||
&environment,
|
||||
&turn,
|
||||
TerminalSandboxSource::Native,
|
||||
Some(grants),
|
||||
);
|
||||
let expected = SandboxPermissions::WithAdditionalPermissions;
|
||||
assert_eq!(
|
||||
permissions.review_requirement(¤t, environment.permission_profile()),
|
||||
Ok(expected)
|
||||
);
|
||||
assert_eq!(permissions.additional_permissions, None);
|
||||
insta::assert_snapshot!("internal_grant", permissions.approval_reason(expected)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case::test_case(TerminalSandboxSource::Native, SandboxPermissions::RequireEscalated; "native_disabled_sandbox_needs_review_when_enabled")]
|
||||
#[test_case::test_case(TerminalSandboxSource::Executor, SandboxPermissions::UseDefault; "executor_keeps_its_restricted_token_default")]
|
||||
#[tokio::test]
|
||||
async fn enabling_windows_sandbox_respects_the_launch_backend(
|
||||
source: TerminalSandboxSource,
|
||||
expected: SandboxPermissions,
|
||||
) -> anyhow::Result<()> {
|
||||
let (_session, turn) = make_session_and_context().await;
|
||||
let mut environment = turn.environments.primary().expect("environment").clone();
|
||||
environment.selection.cwd = PathUri::parse("file:///C:/workspace")?;
|
||||
environment.config_mut().permission_profile =
|
||||
PermissionProfileSnapshot::legacy(PermissionProfile::read_only());
|
||||
environment.config_mut().windows_sandbox_level = WindowsSandboxLevel::Disabled;
|
||||
let permissions = TerminalPermissions::for_launch(
|
||||
&environment,
|
||||
&turn,
|
||||
source,
|
||||
SandboxPermissions::UseDefault,
|
||||
/*additional_permissions*/ None,
|
||||
/*internal_permissions*/ None,
|
||||
);
|
||||
let current = TerminalPolicy::capture(
|
||||
&environment,
|
||||
&turn,
|
||||
source,
|
||||
/*additional_permissions*/ None,
|
||||
);
|
||||
assert_eq!(
|
||||
permissions.review_requirement(¤t, environment.permission_profile()),
|
||||
Ok(SandboxPermissions::UseDefault)
|
||||
);
|
||||
environment.config_mut().windows_sandbox_level = WindowsSandboxLevel::RestrictedToken;
|
||||
let current = TerminalPolicy::capture(
|
||||
&environment,
|
||||
&turn,
|
||||
source,
|
||||
/*additional_permissions*/ None,
|
||||
);
|
||||
assert_eq!(
|
||||
permissions.review_requirement(¤t, environment.permission_profile()),
|
||||
Ok(expected)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -172,6 +172,7 @@ mod turn_input_submission;
|
||||
mod turn_state;
|
||||
mod unified_exec;
|
||||
mod unified_exec_process_events;
|
||||
mod unified_exec_stdin_approval;
|
||||
mod unified_exec_stdin_review_size;
|
||||
#[cfg(unix)]
|
||||
mod unified_exec_zsh_fork_approvals;
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
//! Exercises retained terminal grants and strict stdin review through the agent API.
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core::TurnInputRequest;
|
||||
use codex_core::config::Constrained;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::approvals::ExecApprovalKind;
|
||||
use codex_protocol::models::AdditionalPermissionProfile;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::GranularApprovalConfig;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::ThreadSettingsOverrides;
|
||||
use codex_protocol::request_permissions::PermissionGrantScope;
|
||||
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
use core_test_support::responses::mount_function_call_agent_response;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::skip_if_sandbox;
|
||||
use core_test_support::skip_if_target_windows;
|
||||
use core_test_support::test_codex::TestCodexHarness;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
|
||||
fn tool_response(id: &str, tool: &str, args: Value) -> String {
|
||||
sse(vec![
|
||||
ev_function_call(id, tool, &args.to_string()),
|
||||
ev_completed(id),
|
||||
])
|
||||
}
|
||||
|
||||
async fn harness() -> Result<TestCodexHarness> {
|
||||
TestCodexHarness::with_auto_env_builder(test_codex().with_config(|config| {
|
||||
for feature in [
|
||||
Feature::WriteStdinApproval,
|
||||
Feature::ExecPermissionApprovals,
|
||||
Feature::RequestPermissionsTool,
|
||||
] {
|
||||
config
|
||||
.features
|
||||
.enable(feature)
|
||||
.expect("enable stdin review");
|
||||
}
|
||||
config
|
||||
.permissions
|
||||
.set_permission_profile(PermissionProfile::read_only())
|
||||
.expect("set read-only permissions");
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
}))
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stdin_reviews_retained_grants_after_turn_permissions_expire() -> Result<()> {
|
||||
skip_if_target_windows!(Ok(()), "uses a POSIX interactive shell");
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_sandbox!(Ok(()));
|
||||
let harness = harness().await?;
|
||||
let test = harness.test();
|
||||
harness.create_dir_all("allowed").await?;
|
||||
let directory = test
|
||||
.fs()
|
||||
.canonicalize(&test.workspace_path_uri("allowed")?, /*sandbox*/ None)
|
||||
.await?;
|
||||
let permissions = json!({"file_system": {"write": [directory.to_abs_path()?]}});
|
||||
let grant = mount_sse_once(
|
||||
harness.server(),
|
||||
tool_response(
|
||||
"grant",
|
||||
"request_permissions",
|
||||
json!({"reason":"allow terminal writes", "permissions":permissions}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let opened = mount_function_call_agent_response(
|
||||
harness.server(),
|
||||
"open",
|
||||
&json!({"cmd":"/bin/bash --noprofile --norc", "tty":true, "yield_time_ms":200}).to_string(),
|
||||
"exec_command",
|
||||
)
|
||||
.await;
|
||||
test.codex
|
||||
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
||||
text: "open terminal".into(),
|
||||
text_elements: Vec::new(),
|
||||
}]))
|
||||
.await?;
|
||||
let request = wait_for_event_match(&test.codex, |event| match event {
|
||||
EventMsg::RequestPermissions(request) => Some(request.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
test.codex
|
||||
.submit(Op::RequestPermissionsResponse {
|
||||
id: request.call_id,
|
||||
response: RequestPermissionsResponse {
|
||||
permissions: request.permissions,
|
||||
scope: PermissionGrantScope::Turn,
|
||||
strict_auto_review: false,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
assert_eq!(grant.requests().len(), 1);
|
||||
let output = opened
|
||||
.completion
|
||||
.single_request()
|
||||
.function_call_output("open");
|
||||
assert!(
|
||||
output
|
||||
.to_string()
|
||||
.contains("Process running with session ID 1000"),
|
||||
"{output}"
|
||||
);
|
||||
|
||||
let writes = mount_sse_sequence(harness.server(), vec![
|
||||
tool_response("poll", "write_stdin", json!({"session_id":1000, "chars":"", "yield_time_ms":1000})),
|
||||
tool_response("denied", "write_stdin", json!({"session_id":1000, "chars":"REJECTED=1\n"})),
|
||||
tool_response("allowed", "write_stdin", json!({"session_id":1000, "chars":"test -z \"${REJECTED-}\" && printf allowed > allowed/result; exit\n"})),
|
||||
sse(vec![ev_completed("done")]),
|
||||
]).await;
|
||||
test.codex
|
||||
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
||||
text: "send input".into(),
|
||||
text_elements: Vec::new(),
|
||||
}]))
|
||||
.await?;
|
||||
for (id, decision) in [
|
||||
("denied", ReviewDecision::denied("blocked input")),
|
||||
("allowed", ReviewDecision::Approved),
|
||||
] {
|
||||
let request = wait_for_event_match(&test.codex, |event| match event {
|
||||
EventMsg::ExecApprovalRequest(request) => Some(request.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
(
|
||||
request.kind,
|
||||
request.call_id.as_str(),
|
||||
request.effective_approval_id().as_str()
|
||||
),
|
||||
(ExecApprovalKind::WriteStdin, "open", id)
|
||||
);
|
||||
assert_eq!(
|
||||
request.additional_permissions,
|
||||
Some(serde_json::from_value::<AdditionalPermissionProfile>(
|
||||
permissions.clone()
|
||||
)?)
|
||||
);
|
||||
test.codex
|
||||
.submit(Op::ExecApproval {
|
||||
id: id.into(),
|
||||
turn_id: Some(request.turn_id),
|
||||
decision,
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
let output = writes
|
||||
.requests()
|
||||
.last()
|
||||
.unwrap()
|
||||
.function_call_output("allowed");
|
||||
assert!(
|
||||
output.to_string().contains("Process exited with code 0"),
|
||||
"{output}"
|
||||
);
|
||||
assert_eq!(harness.read_file_text("allowed/result").await?, "allowed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn strict_stdin_review_reaches_guardian_with_sandbox_prompts_disabled() -> Result<()> {
|
||||
skip_if_target_windows!(Ok(()), "uses a POSIX interactive shell");
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_sandbox!(Ok(()));
|
||||
let harness = harness().await?;
|
||||
let test = harness.test();
|
||||
let opened = mount_sse_sequence(
|
||||
harness.server(),
|
||||
vec![
|
||||
tool_response(
|
||||
"open",
|
||||
"exec_command",
|
||||
json!({"cmd":"/bin/bash --noprofile --norc", "tty":true, "yield_time_ms":200}),
|
||||
),
|
||||
tool_response(
|
||||
"baseline",
|
||||
"write_stdin",
|
||||
json!({"session_id":1000, "chars":"INITIAL=1\n"}),
|
||||
),
|
||||
sse(vec![ev_completed("opened")]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
harness
|
||||
.submit_with_permission_profile("open terminal", PermissionProfile::read_only())
|
||||
.await?;
|
||||
assert_eq!(opened.requests().len(), 3);
|
||||
let decision = |outcome: &str| {
|
||||
sse(vec![
|
||||
ev_assistant_message(outcome, &json!({"risk_level":"low", "user_authorization":"high", "outcome":outcome, "rationale":"stdin test"}).to_string()),
|
||||
ev_completed(outcome),
|
||||
])
|
||||
};
|
||||
let writes = mount_sse_sequence(harness.server(), vec![
|
||||
tool_response("strict", "request_permissions", json!({"reason":"review later input", "permissions":{"network":{"enabled":true}}})),
|
||||
tool_response("poll", "write_stdin", json!({"session_id":1000, "chars":"", "yield_time_ms":1000})),
|
||||
tool_response("denied", "write_stdin", json!({"session_id":1000, "chars":"REJECTED=1\n"})),
|
||||
decision("deny"),
|
||||
tool_response("allowed", "write_stdin", json!({"session_id":1000, "chars":"test \"${INITIAL-}\" = 1 && test -z \"${REJECTED-}\" && printf 'STDIN_%s_OK\\n' REVIEW; exit\n"})),
|
||||
decision("allow"),
|
||||
sse(vec![ev_completed("done")]),
|
||||
]).await;
|
||||
test.codex
|
||||
.start_or_steer_turn(
|
||||
TurnInputRequest::user_input(vec![UserInput::Text {
|
||||
text: "send input".into(),
|
||||
text_elements: Vec::new(),
|
||||
}])
|
||||
.with_thread_settings(ThreadSettingsOverrides {
|
||||
approval_policy: Some(AskForApproval::Granular(GranularApprovalConfig {
|
||||
sandbox_approval: false,
|
||||
rules: false,
|
||||
skill_approval: true,
|
||||
request_permissions: true,
|
||||
mcp_elicitations: false,
|
||||
})),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
let request = wait_for_event_match(&test.codex, |event| match event {
|
||||
EventMsg::RequestPermissions(request) => Some(request.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
test.codex
|
||||
.submit(Op::RequestPermissionsResponse {
|
||||
id: request.call_id,
|
||||
response: RequestPermissionsResponse {
|
||||
permissions: request.permissions,
|
||||
scope: PermissionGrantScope::Turn,
|
||||
strict_auto_review: true,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
let requests = writes.requests();
|
||||
assert_eq!(
|
||||
requests
|
||||
.iter()
|
||||
.filter(|request| request.body_contains_text("Planned action JSON:"))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
let output = requests.last().unwrap().function_call_output("allowed");
|
||||
assert!(output.to_string().contains("STDIN_REVIEW_OK"), "{output}");
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user