mirror of
https://github.com/openai/codex.git
synced 2026-03-04 05:33:19 +00:00
Compare commits
17 Commits
codex-cli-
...
pr-11236
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
795c23fae2 | ||
|
|
3e794e1ca6 | ||
|
|
e891f2a923 | ||
|
|
fe407e3f57 | ||
|
|
fb636e57ae | ||
|
|
04fdb6145f | ||
|
|
050dd7c575 | ||
|
|
1241fe8078 | ||
|
|
37b56a45c9 | ||
|
|
1a5c8f46bf | ||
|
|
87d68c24c9 | ||
|
|
39e9796a3e | ||
|
|
3c71a8a6b7 | ||
|
|
8e6faca287 | ||
|
|
79573f1224 | ||
|
|
619a719993 | ||
|
|
ddb4c2b28c |
@@ -1457,6 +1457,17 @@
|
||||
"description": "The command's working directory.",
|
||||
"type": "string"
|
||||
},
|
||||
"network_approval_context": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/NetworkApprovalContext"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional network context for a blocked request that can be approved."
|
||||
},
|
||||
"parsed_cmd": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ParsedCommand"
|
||||
@@ -3317,6 +3328,28 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"NetworkApprovalContext": {
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string"
|
||||
},
|
||||
"protocol": {
|
||||
"$ref": "#/definitions/NetworkApprovalProtocol"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"host",
|
||||
"protocol"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NetworkApprovalProtocol": {
|
||||
"enum": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ParsedCommand": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -6226,6 +6259,17 @@
|
||||
"description": "The command's working directory.",
|
||||
"type": "string"
|
||||
},
|
||||
"network_approval_context": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/NetworkApprovalContext"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional network context for a blocked request that can be approved."
|
||||
},
|
||||
"parsed_cmd": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ParsedCommand"
|
||||
|
||||
@@ -2101,6 +2101,17 @@
|
||||
"description": "The command's working directory.",
|
||||
"type": "string"
|
||||
},
|
||||
"network_approval_context": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/NetworkApprovalContext"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional network context for a blocked request that can be approved."
|
||||
},
|
||||
"parsed_cmd": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ParsedCommand"
|
||||
@@ -4162,6 +4173,28 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"NetworkApprovalContext": {
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string"
|
||||
},
|
||||
"protocol": {
|
||||
"$ref": "#/definitions/NetworkApprovalProtocol"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"host",
|
||||
"protocol"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NetworkApprovalProtocol": {
|
||||
"enum": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ParsedCommand": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -3470,6 +3470,17 @@
|
||||
"description": "The command's working directory.",
|
||||
"type": "string"
|
||||
},
|
||||
"network_approval_context": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/NetworkApprovalContext"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional network context for a blocked request that can be approved."
|
||||
},
|
||||
"parsed_cmd": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ParsedCommand"
|
||||
@@ -6228,6 +6239,28 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"NetworkApprovalContext": {
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string"
|
||||
},
|
||||
"protocol": {
|
||||
"$ref": "#/definitions/NetworkApprovalProtocol"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"host",
|
||||
"protocol"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NetworkApprovalProtocol": {
|
||||
"enum": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"NewConversationParams": {
|
||||
"properties": {
|
||||
"approvalPolicy": {
|
||||
|
||||
@@ -1457,6 +1457,17 @@
|
||||
"description": "The command's working directory.",
|
||||
"type": "string"
|
||||
},
|
||||
"network_approval_context": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/NetworkApprovalContext"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional network context for a blocked request that can be approved."
|
||||
},
|
||||
"parsed_cmd": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ParsedCommand"
|
||||
@@ -3317,6 +3328,28 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"NetworkApprovalContext": {
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string"
|
||||
},
|
||||
"protocol": {
|
||||
"$ref": "#/definitions/NetworkApprovalProtocol"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"host",
|
||||
"protocol"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NetworkApprovalProtocol": {
|
||||
"enum": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ParsedCommand": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -1457,6 +1457,17 @@
|
||||
"description": "The command's working directory.",
|
||||
"type": "string"
|
||||
},
|
||||
"network_approval_context": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/NetworkApprovalContext"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional network context for a blocked request that can be approved."
|
||||
},
|
||||
"parsed_cmd": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ParsedCommand"
|
||||
@@ -3317,6 +3328,28 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"NetworkApprovalContext": {
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string"
|
||||
},
|
||||
"protocol": {
|
||||
"$ref": "#/definitions/NetworkApprovalProtocol"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"host",
|
||||
"protocol"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NetworkApprovalProtocol": {
|
||||
"enum": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ParsedCommand": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -1457,6 +1457,17 @@
|
||||
"description": "The command's working directory.",
|
||||
"type": "string"
|
||||
},
|
||||
"network_approval_context": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/NetworkApprovalContext"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional network context for a blocked request that can be approved."
|
||||
},
|
||||
"parsed_cmd": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ParsedCommand"
|
||||
@@ -3317,6 +3328,28 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"NetworkApprovalContext": {
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string"
|
||||
},
|
||||
"protocol": {
|
||||
"$ref": "#/definitions/NetworkApprovalProtocol"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"host",
|
||||
"protocol"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NetworkApprovalProtocol": {
|
||||
"enum": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ParsedCommand": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ExecPolicyAmendment } from "./ExecPolicyAmendment";
|
||||
import type { NetworkApprovalContext } from "./NetworkApprovalContext";
|
||||
import type { ParsedCommand } from "./ParsedCommand";
|
||||
|
||||
export type ExecApprovalRequestEvent = {
|
||||
@@ -26,6 +27,10 @@ cwd: string,
|
||||
* Optional human-readable reason for the approval (e.g. retry without sandbox).
|
||||
*/
|
||||
reason: string | null,
|
||||
/**
|
||||
* Optional network context for a blocked request that can be approved.
|
||||
*/
|
||||
network_approval_context?: NetworkApprovalContext,
|
||||
/**
|
||||
* Proposed execpolicy amendment that can be applied to allow future runs.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol";
|
||||
|
||||
export type NetworkApprovalContext = { host: string, protocol: NetworkApprovalProtocol, };
|
||||
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type NetworkApprovalProtocol = "http" | "https";
|
||||
@@ -123,6 +123,8 @@ export type { McpToolCallEndEvent } from "./McpToolCallEndEvent";
|
||||
export type { MessagePhase } from "./MessagePhase";
|
||||
export type { ModeKind } from "./ModeKind";
|
||||
export type { NetworkAccess } from "./NetworkAccess";
|
||||
export type { NetworkApprovalContext } from "./NetworkApprovalContext";
|
||||
export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol";
|
||||
export type { NewConversationParams } from "./NewConversationParams";
|
||||
export type { NewConversationResponse } from "./NewConversationResponse";
|
||||
export type { ParsedCommand } from "./ParsedCommand";
|
||||
|
||||
@@ -205,6 +205,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
reason,
|
||||
proposed_execpolicy_amendment,
|
||||
parsed_cmd,
|
||||
network_approval_context: _,
|
||||
}) => match api_version {
|
||||
ApiVersion::V1 => {
|
||||
let params = ExecCommandApprovalParams {
|
||||
|
||||
@@ -1657,8 +1657,24 @@ impl CodexMessageProcessor {
|
||||
let timeout_ms = params
|
||||
.timeout_ms
|
||||
.and_then(|timeout_ms| u64::try_from(timeout_ms).ok());
|
||||
let requested_policy = params.sandbox_policy.map(|policy| policy.to_core());
|
||||
let effective_policy = match requested_policy {
|
||||
Some(policy) => match self.config.sandbox_policy.can_set(&policy) {
|
||||
Ok(()) => policy,
|
||||
Err(err) => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("invalid sandbox policy: {err}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request, error).await;
|
||||
return;
|
||||
}
|
||||
},
|
||||
None => self.config.sandbox_policy.get().clone(),
|
||||
};
|
||||
let started_network_proxy = match self.config.network.as_ref() {
|
||||
Some(spec) => match spec.start_proxy().await {
|
||||
Some(spec) => match spec.start_proxy(&effective_policy).await {
|
||||
Ok(started) => Some(started),
|
||||
Err(err) => {
|
||||
let error = JSONRPCErrorError {
|
||||
@@ -1687,23 +1703,6 @@ impl CodexMessageProcessor {
|
||||
arg0: None,
|
||||
};
|
||||
|
||||
let requested_policy = params.sandbox_policy.map(|policy| policy.to_core());
|
||||
let effective_policy = match requested_policy {
|
||||
Some(policy) => match self.config.sandbox_policy.can_set(&policy) {
|
||||
Ok(()) => policy,
|
||||
Err(err) => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("invalid sandbox policy: {err}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request, error).await;
|
||||
return;
|
||||
}
|
||||
},
|
||||
None => self.config.sandbox_policy.get().clone(),
|
||||
};
|
||||
|
||||
let codex_linux_sandbox_exe = self.config.codex_linux_sandbox_exe.clone();
|
||||
let outgoing = self.outgoing.clone();
|
||||
let request_for_task = request;
|
||||
|
||||
@@ -216,7 +216,7 @@ async fn run_command_under_sandbox(
|
||||
// This proxy should only live for the lifetime of the child process.
|
||||
let network_proxy = match config.network.as_ref() {
|
||||
Some(spec) => Some(
|
||||
spec.start_proxy()
|
||||
spec.start_proxy(config.sandbox_policy.get())
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?,
|
||||
),
|
||||
|
||||
@@ -169,6 +169,7 @@ use crate::protocol::Event;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::ExecApprovalRequestEvent;
|
||||
use crate::protocol::McpServerRefreshConfig;
|
||||
use crate::protocol::NetworkApprovalContext;
|
||||
use crate::protocol::Op;
|
||||
use crate::protocol::PlanDeltaEvent;
|
||||
use crate::protocol::RateLimitSnapshot;
|
||||
@@ -1075,13 +1076,16 @@ impl Session {
|
||||
};
|
||||
session_configuration.thread_name = thread_name.clone();
|
||||
let mut state = SessionState::new(session_configuration.clone());
|
||||
let network_proxy =
|
||||
match config.network.as_ref() {
|
||||
Some(spec) => Some(spec.start_proxy().await.map_err(|err| {
|
||||
anyhow::anyhow!("failed to start managed network proxy: {err}")
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
let network_proxy = match config.network.as_ref() {
|
||||
Some(spec) => Some(
|
||||
spec.start_proxy(config.sandbox_policy.get())
|
||||
.await
|
||||
.map_err(|err| {
|
||||
anyhow::anyhow!("failed to start managed network proxy: {err}")
|
||||
})?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let session_network_proxy = network_proxy.as_ref().map(|started| {
|
||||
let proxy = started.proxy();
|
||||
SessionNetworkProxyRuntime {
|
||||
@@ -1964,6 +1968,7 @@ impl Session {
|
||||
command: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
reason: Option<String>,
|
||||
network_approval_context: Option<NetworkApprovalContext>,
|
||||
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
||||
) -> ReviewDecision {
|
||||
// Add the tx_approve callback to the map before sending the request.
|
||||
@@ -1990,6 +1995,7 @@ impl Session {
|
||||
command,
|
||||
cwd,
|
||||
reason,
|
||||
network_approval_context,
|
||||
proposed_execpolicy_amendment,
|
||||
parsed_cmd,
|
||||
});
|
||||
|
||||
@@ -315,6 +315,7 @@ async fn handle_exec_approval(
|
||||
command,
|
||||
cwd,
|
||||
reason,
|
||||
network_approval_context,
|
||||
proposed_execpolicy_amendment,
|
||||
..
|
||||
} = event;
|
||||
@@ -326,6 +327,7 @@ async fn handle_exec_approval(
|
||||
command,
|
||||
cwd,
|
||||
reason,
|
||||
network_approval_context,
|
||||
proposed_execpolicy_amendment,
|
||||
);
|
||||
let decision =
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::config_loader::NetworkConstraints;
|
||||
use async_trait::async_trait;
|
||||
use codex_network_proxy::ConfigReloader;
|
||||
use codex_network_proxy::ConfigState;
|
||||
use codex_network_proxy::NetworkDecision;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
use codex_network_proxy::NetworkProxyConstraints;
|
||||
@@ -11,6 +12,7 @@ use codex_network_proxy::NetworkProxyState;
|
||||
use codex_network_proxy::build_config_state;
|
||||
use codex_network_proxy::host_and_port_from_network_addr;
|
||||
use codex_network_proxy::validate_policy_against_constraints;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -92,20 +94,27 @@ impl NetworkProxySpec {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn start_proxy(&self) -> std::io::Result<StartedNetworkProxy> {
|
||||
pub async fn start_proxy(
|
||||
&self,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
) -> std::io::Result<StartedNetworkProxy> {
|
||||
let state =
|
||||
build_config_state(self.config.clone(), self.constraints.clone()).map_err(|err| {
|
||||
std::io::Error::other(format!("failed to build network proxy state: {err}"))
|
||||
})?;
|
||||
let reloader = Arc::new(StaticNetworkProxyReloader::new(state.clone()));
|
||||
let state = NetworkProxyState::with_reloader(state, reloader);
|
||||
let proxy = NetworkProxy::builder()
|
||||
.state(Arc::new(state))
|
||||
.build()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
std::io::Error::other(format!("failed to build network proxy: {err}"))
|
||||
})?;
|
||||
let mut builder = NetworkProxy::builder().state(Arc::new(state));
|
||||
if should_ask_on_allowlist_miss(sandbox_policy) {
|
||||
builder = builder.policy_decider(|_request| async {
|
||||
// In restricted sandbox modes, allowlist misses should ask for
|
||||
// explicit network approval instead of hard-denying.
|
||||
NetworkDecision::ask("not_allowed")
|
||||
});
|
||||
}
|
||||
let proxy = builder.build().await.map_err(|err| {
|
||||
std::io::Error::other(format!("failed to build network proxy: {err}"))
|
||||
})?;
|
||||
let handle = proxy
|
||||
.run()
|
||||
.await
|
||||
@@ -169,3 +178,41 @@ impl NetworkProxySpec {
|
||||
(config, constraints)
|
||||
}
|
||||
}
|
||||
|
||||
fn should_ask_on_allowlist_miss(sandbox_policy: &SandboxPolicy) -> bool {
|
||||
matches!(
|
||||
sandbox_policy,
|
||||
SandboxPolicy::ReadOnly | SandboxPolicy::WorkspaceWrite { .. }
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::protocol::NetworkAccess;
|
||||
|
||||
#[test]
|
||||
fn restricted_sandbox_modes_ask_on_allowlist_miss() {
|
||||
assert!(should_ask_on_allowlist_miss(&SandboxPolicy::ReadOnly));
|
||||
assert!(should_ask_on_allowlist_miss(
|
||||
&SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yolo_and_external_modes_do_not_ask_on_allowlist_miss() {
|
||||
assert!(!should_ask_on_allowlist_miss(
|
||||
&SandboxPolicy::DangerFullAccess
|
||||
));
|
||||
assert!(!should_ask_on_allowlist_miss(
|
||||
&SandboxPolicy::ExternalSandbox {
|
||||
network_access: NetworkAccess::Restricted,
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::error::CodexErr;
|
||||
use crate::error::Result;
|
||||
use crate::error::SandboxErr;
|
||||
use crate::get_platform_sandbox;
|
||||
use crate::network_policy_decision::extract_network_policy_decisions;
|
||||
use crate::protocol::Event;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::ExecCommandOutputDeltaEvent;
|
||||
@@ -530,7 +531,15 @@ pub(crate) fn is_likely_sandbox_denied(
|
||||
sandbox_type: SandboxType,
|
||||
exec_output: &ExecToolCallOutput,
|
||||
) -> bool {
|
||||
if sandbox_type == SandboxType::None || exec_output.exit_code == 0 {
|
||||
if sandbox_type == SandboxType::None {
|
||||
return false;
|
||||
}
|
||||
|
||||
if has_network_policy_blocking_decision(exec_output) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if exec_output.exit_code == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -583,6 +592,17 @@ pub(crate) fn is_likely_sandbox_denied(
|
||||
false
|
||||
}
|
||||
|
||||
fn has_network_policy_blocking_decision(exec_output: &ExecToolCallOutput) -> bool {
|
||||
[
|
||||
&exec_output.stderr.text,
|
||||
&exec_output.stdout.text,
|
||||
&exec_output.aggregated_output.text,
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|section| extract_network_policy_decisions(section))
|
||||
.any(|payload| payload.is_blocking_decision())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamOutput<T: Clone> {
|
||||
pub text: T,
|
||||
@@ -965,6 +985,57 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_detection_flags_network_policy_ask_with_zero_exit_code() {
|
||||
let output = make_exec_output(
|
||||
0,
|
||||
"",
|
||||
"",
|
||||
r#"CODEX_NETWORK_POLICY_DECISION {"decision":"ask","reason":"not_allowed","source":"decider","protocol":"http","host":"google.com","port":80}"#,
|
||||
);
|
||||
|
||||
assert!(is_likely_sandbox_denied(SandboxType::LinuxSeccomp, &output));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_detection_flags_network_policy_non_decider_with_zero_exit_code() {
|
||||
let output = make_exec_output(
|
||||
0,
|
||||
"",
|
||||
"",
|
||||
r#"CODEX_NETWORK_POLICY_DECISION {"decision":"ask","reason":"not_allowed","source":"baseline_policy","protocol":"http","host":"google.com","port":80}"#,
|
||||
);
|
||||
|
||||
assert!(is_likely_sandbox_denied(SandboxType::LinuxSeccomp, &output));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_detection_ignores_network_policy_allow_with_zero_exit_code() {
|
||||
let output = make_exec_output(
|
||||
0,
|
||||
"",
|
||||
"",
|
||||
r#"CODEX_NETWORK_POLICY_DECISION {"decision":"allow","source":"decider","protocol":"http","host":"google.com","port":80}"#,
|
||||
);
|
||||
|
||||
assert!(!is_likely_sandbox_denied(
|
||||
SandboxType::LinuxSeccomp,
|
||||
&output
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_detection_flags_network_policy_ask_from_json_blocked_response() {
|
||||
let output = make_exec_output(
|
||||
0,
|
||||
"",
|
||||
"",
|
||||
r#"{"status":"blocked","host":"google.com","reason":"not_allowed","policy_decision_prefix":"CODEX_NETWORK_POLICY_DECISION {\"decision\":\"ask\",\"reason\":\"not_allowed\",\"source\":\"decider\",\"protocol\":\"http\",\"host\":\"google.com\",\"port\":80}","message":"CODEX_NETWORK_POLICY_DECISION {\"decision\":\"ask\",\"reason\":\"not_allowed\",\"source\":\"decider\",\"protocol\":\"http\",\"host\":\"google.com\",\"port\":80}\nCodex blocked this request: domain not in allowlist (this is not a denylist block)."}"#,
|
||||
);
|
||||
|
||||
assert!(is_likely_sandbox_denied(SandboxType::LinuxSeccomp, &output));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_capped_limits_retained_bytes() {
|
||||
let (mut writer, reader) = tokio::io::duplex(1024);
|
||||
|
||||
@@ -41,6 +41,7 @@ pub mod landlock;
|
||||
pub mod mcp;
|
||||
mod mcp_connection_manager;
|
||||
pub mod models_manager;
|
||||
mod network_policy_decision;
|
||||
pub mod network_proxy_loader;
|
||||
pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY;
|
||||
pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD;
|
||||
|
||||
123
codex-rs/core/src/network_policy_decision.rs
Normal file
123
codex-rs/core/src/network_policy_decision.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
pub(crate) const NETWORK_POLICY_DECISION_PREFIX: &str = "CODEX_NETWORK_POLICY_DECISION ";
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct NetworkPolicyDecisionPayload {
|
||||
pub decision: String,
|
||||
pub source: String,
|
||||
pub protocol: Option<String>,
|
||||
pub host: Option<String>,
|
||||
pub reason: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
impl NetworkPolicyDecisionPayload {
|
||||
pub(crate) fn is_ask_from_decider(&self) -> bool {
|
||||
self.decision.eq_ignore_ascii_case("ask") && self.source.eq_ignore_ascii_case("decider")
|
||||
}
|
||||
|
||||
pub(crate) fn is_blocking_decision(&self) -> bool {
|
||||
!self.decision.eq_ignore_ascii_case("allow")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn extract_network_policy_decisions(text: &str) -> Vec<NetworkPolicyDecisionPayload> {
|
||||
text.lines()
|
||||
.flat_map(extract_policy_decisions_from_fragment)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extract_policy_decisions_from_fragment(fragment: &str) -> Vec<NetworkPolicyDecisionPayload> {
|
||||
let mut payloads = Vec::new();
|
||||
|
||||
if let Some(payload) = parse_prefixed_payload(fragment) {
|
||||
payloads.push(payload);
|
||||
}
|
||||
|
||||
if let Ok(value) = serde_json::from_str::<Value>(fragment) {
|
||||
extract_policy_decisions_from_json_value(&value, &mut payloads);
|
||||
}
|
||||
|
||||
payloads
|
||||
}
|
||||
|
||||
fn extract_policy_decisions_from_json_value(
|
||||
value: &Value,
|
||||
payloads: &mut Vec<NetworkPolicyDecisionPayload>,
|
||||
) {
|
||||
match value {
|
||||
Value::String(text) => {
|
||||
payloads.extend(text.lines().filter_map(parse_prefixed_payload));
|
||||
}
|
||||
Value::Array(values) => {
|
||||
for value in values {
|
||||
extract_policy_decisions_from_json_value(value, payloads);
|
||||
}
|
||||
}
|
||||
Value::Object(map) => {
|
||||
for value in map.values() {
|
||||
extract_policy_decisions_from_json_value(value, payloads);
|
||||
}
|
||||
}
|
||||
Value::Null | Value::Bool(_) | Value::Number(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_prefixed_payload(text: &str) -> Option<NetworkPolicyDecisionPayload> {
|
||||
let payload = text.strip_prefix(NETWORK_POLICY_DECISION_PREFIX)?;
|
||||
serde_json::from_str::<NetworkPolicyDecisionPayload>(payload).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn extracts_payload_from_prefixed_line() {
|
||||
let text = r#"CODEX_NETWORK_POLICY_DECISION {"decision":"ask","source":"decider","protocol":"http","host":"example.com","port":80}"#;
|
||||
|
||||
let payloads = extract_network_policy_decisions(text);
|
||||
assert_eq!(
|
||||
payloads,
|
||||
vec![NetworkPolicyDecisionPayload {
|
||||
decision: "ask".to_string(),
|
||||
source: "decider".to_string(),
|
||||
protocol: Some("http".to_string()),
|
||||
host: Some("example.com".to_string()),
|
||||
reason: None,
|
||||
port: Some(80),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_payload_from_generic_json_string_field() {
|
||||
let text = r#"{"unexpected":"CODEX_NETWORK_POLICY_DECISION {\"decision\":\"deny\",\"source\":\"baseline_policy\",\"protocol\":\"https_connect\",\"host\":\"google.com\",\"port\":443}"}"#;
|
||||
|
||||
let payloads = extract_network_policy_decisions(text);
|
||||
assert_eq!(payloads.len(), 1);
|
||||
assert_eq!(payloads[0].decision, "deny");
|
||||
assert_eq!(payloads[0].source, "baseline_policy");
|
||||
assert_eq!(payloads[0].host.as_deref(), Some("google.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_payload_from_nested_json_values() {
|
||||
let text = r#"{"data":[{"meta":{"message":"CODEX_NETWORK_POLICY_DECISION {\"decision\":\"ask\",\"source\":\"decider\",\"protocol\":\"https_connect\",\"host\":\"api.example.com\",\"port\":443}\nblocked"}}]}"#;
|
||||
|
||||
let payloads = extract_network_policy_decisions(text);
|
||||
assert_eq!(payloads.len(), 1);
|
||||
assert_eq!(payloads[0].decision, "ask");
|
||||
assert_eq!(payloads[0].host.as_deref(), Some("api.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_lines_without_policy_prefix() {
|
||||
let text = r#"{"status":"blocked","message":"domain not in allowlist"}"#;
|
||||
assert!(extract_network_policy_decisions(text).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use crate::error::CodexErr;
|
||||
use crate::error::SandboxErr;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::features::Feature;
|
||||
use crate::network_policy_decision::extract_network_policy_decisions;
|
||||
use crate::sandboxing::SandboxManager;
|
||||
use crate::tools::sandboxing::ApprovalCtx;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
@@ -20,6 +21,8 @@ use crate::tools::sandboxing::ToolError;
|
||||
use crate::tools::sandboxing::ToolRuntime;
|
||||
use crate::tools::sandboxing::default_exec_approval_requirement;
|
||||
use codex_otel::ToolDecisionSource;
|
||||
use codex_protocol::approvals::NetworkApprovalContext;
|
||||
use codex_protocol::approvals::NetworkApprovalProtocol;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
|
||||
@@ -70,6 +73,7 @@ impl ToolOrchestrator {
|
||||
turn: turn_ctx,
|
||||
call_id: &tool_ctx.call_id,
|
||||
retry_reason: reason,
|
||||
network_approval_context: None,
|
||||
};
|
||||
let decision = tool.start_approval_async(req, approval_ctx).await;
|
||||
|
||||
@@ -129,22 +133,37 @@ impl ToolOrchestrator {
|
||||
output,
|
||||
})));
|
||||
}
|
||||
// Under `Never` or `OnRequest`, do not retry without sandbox; surface a concise
|
||||
// sandbox denial that preserves the original output.
|
||||
if !tool.wants_no_sandbox_approval(approval_policy) {
|
||||
let retry_details = build_denial_reason_from_output(
|
||||
output.as_ref(),
|
||||
should_prompt_for_network_approval(turn_ctx),
|
||||
);
|
||||
|
||||
// Most tools disallow no-sandbox retry under OnRequest. However, for managed
|
||||
// network denials with extracted network approval context, we still allow
|
||||
// prompting so the user can explicitly approve the specific host/protocol access.
|
||||
if !can_retry_without_sandbox(
|
||||
tool.wants_no_sandbox_approval(approval_policy),
|
||||
approval_policy,
|
||||
&turn_ctx.sandbox_policy,
|
||||
retry_details.network_approval_context.is_some(),
|
||||
) {
|
||||
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
|
||||
output,
|
||||
})));
|
||||
}
|
||||
|
||||
// Ask for approval before retrying with the escalated sandbox.
|
||||
if !tool.should_bypass_approval(approval_policy, already_approved) {
|
||||
let reason_msg = build_denial_reason_from_output(output.as_ref());
|
||||
let should_bypass_retry_approval = should_bypass_retry_approval(
|
||||
tool.should_bypass_approval(approval_policy, already_approved),
|
||||
retry_details.network_approval_context.is_some(),
|
||||
);
|
||||
if !should_bypass_retry_approval {
|
||||
let approval_ctx = ApprovalCtx {
|
||||
session: tool_ctx.session,
|
||||
turn: turn_ctx,
|
||||
call_id: &tool_ctx.call_id,
|
||||
retry_reason: Some(reason_msg),
|
||||
retry_reason: Some(retry_details.reason),
|
||||
network_approval_context: retry_details.network_approval_context.clone(),
|
||||
};
|
||||
|
||||
let decision = tool.start_approval_async(req, approval_ctx).await;
|
||||
@@ -160,6 +179,31 @@ impl ToolOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
let temporary_allowed_host = if let Some(network_approval_context) =
|
||||
retry_details.network_approval_context.as_ref()
|
||||
{
|
||||
if let Some(network) = turn_ctx.network.as_ref() {
|
||||
let granted_host = network
|
||||
.grant_temporary_allowed_host(&network_approval_context.host)
|
||||
.await;
|
||||
if granted_host.is_none() {
|
||||
tracing::warn!(
|
||||
host = %network_approval_context.host,
|
||||
"failed to grant temporary network host allowance; retry may remain blocked"
|
||||
);
|
||||
}
|
||||
granted_host.map(|host| (network.clone(), host))
|
||||
} else {
|
||||
tracing::warn!(
|
||||
host = %network_approval_context.host,
|
||||
"network approval context is present but no managed network proxy is available"
|
||||
);
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let escalated_attempt = SandboxAttempt {
|
||||
sandbox: crate::exec::SandboxType::None,
|
||||
policy: &turn_ctx.sandbox_policy,
|
||||
@@ -172,15 +216,256 @@ impl ToolOrchestrator {
|
||||
};
|
||||
|
||||
// Second attempt.
|
||||
(*tool).run(req, &escalated_attempt, tool_ctx).await
|
||||
let second_attempt = (*tool).run(req, &escalated_attempt, tool_ctx).await;
|
||||
if let Some((network, host)) = temporary_allowed_host {
|
||||
network.revoke_temporary_allowed_host(&host).await;
|
||||
}
|
||||
second_attempt
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_denial_reason_from_output(_output: &ExecToolCallOutput) -> String {
|
||||
// Keep approval reason terse and stable for UX/tests, but accept the
|
||||
// output so we can evolve heuristics later without touching call sites.
|
||||
"command failed; retry without sandbox?".to_string()
|
||||
#[derive(Debug)]
|
||||
struct RetryApprovalDetails {
|
||||
reason: String,
|
||||
network_approval_context: Option<NetworkApprovalContext>,
|
||||
}
|
||||
|
||||
fn build_denial_reason_from_output(
|
||||
output: &ExecToolCallOutput,
|
||||
network_prompting_enabled: bool,
|
||||
) -> RetryApprovalDetails {
|
||||
let network_approval_context = if network_prompting_enabled {
|
||||
extract_network_approval_context(output)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let reason = if let Some(network_approval_context) = network_approval_context.as_ref() {
|
||||
format!(
|
||||
"Network access to \"{}\" is blocked by policy.",
|
||||
network_approval_context.host
|
||||
)
|
||||
} else {
|
||||
// Keep approval reason terse and stable for UX/tests, but accept the
|
||||
// output so we can evolve heuristics later without touching call sites.
|
||||
"command failed; retry without sandbox?".to_string()
|
||||
};
|
||||
RetryApprovalDetails {
|
||||
reason,
|
||||
network_approval_context,
|
||||
}
|
||||
}
|
||||
|
||||
fn should_prompt_for_network_approval(turn_ctx: &crate::codex::TurnContext) -> bool {
|
||||
matches!(
|
||||
turn_ctx
|
||||
.config
|
||||
.config_layer_stack
|
||||
.requirements_toml()
|
||||
.network
|
||||
.as_ref()
|
||||
.and_then(|network| network.enabled),
|
||||
Some(true)
|
||||
)
|
||||
}
|
||||
|
||||
fn can_retry_without_sandbox(
|
||||
tool_wants_no_sandbox_approval: bool,
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
|
||||
has_network_approval_context: bool,
|
||||
) -> bool {
|
||||
if tool_wants_no_sandbox_approval {
|
||||
return true;
|
||||
}
|
||||
|
||||
if !matches!(approval_policy, AskForApproval::OnRequest) || !has_network_approval_context {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Keep retry prompting aligned with command exec approvals for OnRequest:
|
||||
// only restricted sandbox modes (ReadOnly/WorkspaceWrite) should prompt.
|
||||
matches!(
|
||||
default_exec_approval_requirement(approval_policy, sandbox_policy),
|
||||
ExecApprovalRequirement::NeedsApproval { .. }
|
||||
)
|
||||
}
|
||||
|
||||
fn should_bypass_retry_approval(
|
||||
tool_wants_to_bypass_approval: bool,
|
||||
has_network_approval_context: bool,
|
||||
) -> bool {
|
||||
tool_wants_to_bypass_approval && !has_network_approval_context
|
||||
}
|
||||
|
||||
fn extract_network_approval_context(output: &ExecToolCallOutput) -> Option<NetworkApprovalContext> {
|
||||
[
|
||||
output.stderr.text.as_str(),
|
||||
output.stdout.text.as_str(),
|
||||
output.aggregated_output.text.as_str(),
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(extract_network_approval_context_from_text)
|
||||
}
|
||||
|
||||
fn extract_network_approval_context_from_text(text: &str) -> Option<NetworkApprovalContext> {
|
||||
extract_network_policy_decisions(text)
|
||||
.into_iter()
|
||||
.find_map(|payload| {
|
||||
if !payload.is_ask_from_decider() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let protocol = match payload.protocol.as_deref() {
|
||||
Some("http") => NetworkApprovalProtocol::Http,
|
||||
Some("https") | Some("https_connect") => NetworkApprovalProtocol::Https,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let host = payload.host.as_deref()?.trim();
|
||||
if host.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(NetworkApprovalContext {
|
||||
host: host.to_string(),
|
||||
protocol,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::exec::StreamOutput;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn output_with_stderr(stderr: &str) -> ExecToolCallOutput {
|
||||
ExecToolCallOutput {
|
||||
stderr: StreamOutput::new(stderr.to_string()),
|
||||
..ExecToolCallOutput::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_denial_reason_extracts_network_context_when_enabled() {
|
||||
let output = output_with_stderr(
|
||||
"CODEX_NETWORK_POLICY_DECISION {\"decision\":\"ask\",\"source\":\"decider\",\"protocol\":\"https_connect\",\"host\":\"example.com\",\"port\":443}\nblocked",
|
||||
);
|
||||
|
||||
let details = build_denial_reason_from_output(&output, true);
|
||||
|
||||
assert_eq!(
|
||||
details.network_approval_context,
|
||||
Some(NetworkApprovalContext {
|
||||
host: "example.com".to_string(),
|
||||
protocol: NetworkApprovalProtocol::Https,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
details.reason,
|
||||
"Network access to \"example.com\" is blocked by policy."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_denial_reason_skips_network_context_when_disabled() {
|
||||
let output = output_with_stderr(
|
||||
"CODEX_NETWORK_POLICY_DECISION {\"decision\":\"ask\",\"source\":\"decider\",\"protocol\":\"https_connect\",\"host\":\"example.com\",\"port\":443}\nblocked",
|
||||
);
|
||||
|
||||
let details = build_denial_reason_from_output(&output, false);
|
||||
|
||||
assert_eq!(details.network_approval_context, None);
|
||||
assert_eq!(details.reason, "command failed; retry without sandbox?");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_network_approval_context_ignores_non_ask_payloads() {
|
||||
let text = "CODEX_NETWORK_POLICY_DECISION {\"decision\":\"deny\",\"source\":\"decider\",\"protocol\":\"http\",\"host\":\"example.com\",\"port\":80}";
|
||||
|
||||
assert_eq!(extract_network_approval_context_from_text(text), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_network_approval_context_ignores_non_decider_payloads() {
|
||||
let text = "CODEX_NETWORK_POLICY_DECISION {\"decision\":\"ask\",\"source\":\"baseline_policy\",\"protocol\":\"http\",\"host\":\"example.com\",\"port\":80}";
|
||||
|
||||
assert_eq!(extract_network_approval_context_from_text(text), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_network_approval_context_from_json_blocked_response() {
|
||||
let text = r#"{"status":"blocked","host":"example.com","reason":"not_allowed","policy_decision_prefix":"CODEX_NETWORK_POLICY_DECISION {\"decision\":\"ask\",\"reason\":\"not_allowed\",\"source\":\"decider\",\"protocol\":\"https_connect\",\"host\":\"example.com\",\"port\":443}","message":"CODEX_NETWORK_POLICY_DECISION {\"decision\":\"ask\",\"reason\":\"not_allowed\",\"source\":\"decider\",\"protocol\":\"https_connect\",\"host\":\"example.com\",\"port\":443}\nCodex blocked this request: domain not in allowlist (this is not a denylist block)."}"#;
|
||||
|
||||
assert_eq!(
|
||||
extract_network_approval_context_from_text(text),
|
||||
Some(NetworkApprovalContext {
|
||||
host: "example.com".to_string(),
|
||||
protocol: NetworkApprovalProtocol::Https,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_retry_without_sandbox_respects_default_on_request_gate() {
|
||||
assert!(!can_retry_without_sandbox(
|
||||
false,
|
||||
AskForApproval::OnRequest,
|
||||
&codex_protocol::protocol::SandboxPolicy::ReadOnly,
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_retry_without_sandbox_allows_on_request_for_network_context() {
|
||||
assert!(can_retry_without_sandbox(
|
||||
false,
|
||||
AskForApproval::OnRequest,
|
||||
&codex_protocol::protocol::SandboxPolicy::ReadOnly,
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_retry_without_sandbox_blocks_on_request_for_network_context_in_danger_full_access() {
|
||||
assert!(!can_retry_without_sandbox(
|
||||
false,
|
||||
AskForApproval::OnRequest,
|
||||
&codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_retry_without_sandbox_still_blocks_never_without_tool_override() {
|
||||
assert!(!can_retry_without_sandbox(
|
||||
false,
|
||||
AskForApproval::Never,
|
||||
&codex_protocol::protocol::SandboxPolicy::ReadOnly,
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_retry_without_sandbox_honors_tool_override() {
|
||||
assert!(can_retry_without_sandbox(
|
||||
true,
|
||||
AskForApproval::OnRequest,
|
||||
&codex_protocol::protocol::SandboxPolicy::ReadOnly,
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_approval_not_bypassed_when_network_context_present() {
|
||||
assert!(!should_bypass_retry_approval(true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_approval_bypassed_without_network_context() {
|
||||
assert!(should_bypass_retry_approval(true, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ impl Approvable<ShellRequest> for ShellRuntime {
|
||||
command,
|
||||
cwd,
|
||||
reason,
|
||||
ctx.network_approval_context.clone(),
|
||||
req.exec_approval_requirement
|
||||
.proposed_execpolicy_amendment()
|
||||
.cloned(),
|
||||
|
||||
@@ -110,6 +110,7 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
|
||||
command,
|
||||
cwd,
|
||||
reason,
|
||||
ctx.network_approval_context.clone(),
|
||||
req.exec_approval_requirement
|
||||
.proposed_execpolicy_amendment()
|
||||
.cloned(),
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::sandboxing::SandboxTransformError;
|
||||
use crate::state::SessionServices;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkApprovalContext;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use std::collections::HashMap;
|
||||
@@ -110,6 +111,7 @@ pub(crate) struct ApprovalCtx<'a> {
|
||||
pub turn: &'a TurnContext,
|
||||
pub call_id: &'a str,
|
||||
pub retry_reason: Option<String>,
|
||||
pub network_approval_context: Option<NetworkApprovalContext>,
|
||||
}
|
||||
|
||||
// Specifies what tool orchestrator should do with a given tool call.
|
||||
|
||||
@@ -221,6 +221,7 @@ async fn run_codex_tool_session_inner(
|
||||
reason: _,
|
||||
proposed_execpolicy_amendment: _,
|
||||
parsed_cmd,
|
||||
network_approval_context: _,
|
||||
}) => {
|
||||
handle_exec_approval_request(
|
||||
command,
|
||||
|
||||
@@ -119,6 +119,10 @@ impl NetworkDecision {
|
||||
Self::deny_with_source(reason, NetworkDecisionSource::Decider)
|
||||
}
|
||||
|
||||
pub fn ask(reason: impl Into<String>) -> Self {
|
||||
Self::ask_with_source(reason, NetworkDecisionSource::Decider)
|
||||
}
|
||||
|
||||
pub fn deny_with_source(reason: impl Into<String>, source: NetworkDecisionSource) -> Self {
|
||||
let reason = reason.into();
|
||||
let reason = if reason.is_empty() {
|
||||
@@ -216,9 +220,9 @@ fn map_decider_decision(decision: NetworkDecision) -> NetworkDecision {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use crate::config::NetworkProxySettings;
|
||||
use crate::reasons::REASON_DENIED;
|
||||
use crate::reasons::REASON_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_NOT_ALLOWED_LOCAL;
|
||||
use crate::state::network_proxy_state_for_policy;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -336,4 +340,16 @@ mod tests {
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_uses_decider_source_and_ask_decision() {
|
||||
assert_eq!(
|
||||
NetworkDecision::ask(REASON_NOT_ALLOWED),
|
||||
NetworkDecision::Deny {
|
||||
reason: REASON_NOT_ALLOWED.to_string(),
|
||||
source: NetworkDecisionSource::Decider,
|
||||
decision: NetworkPolicyDecision::Ask,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,6 +386,20 @@ impl NetworkProxy {
|
||||
self.admin_addr
|
||||
}
|
||||
|
||||
/// Temporarily allows a host for a single retried command execution.
|
||||
///
|
||||
/// Returns the normalized host token that should be passed to
|
||||
/// `revoke_temporary_allowed_host` when the retry finishes.
|
||||
pub async fn grant_temporary_allowed_host(&self, host: &str) -> Option<String> {
|
||||
self.state.grant_temporary_allowed_host(host).await
|
||||
}
|
||||
|
||||
/// Revokes a temporary host allowance created with
|
||||
/// `grant_temporary_allowed_host`.
|
||||
pub async fn revoke_temporary_allowed_host(&self, host: &str) {
|
||||
self.state.revoke_temporary_allowed_host(host).await;
|
||||
}
|
||||
|
||||
pub fn apply_to_env(&self, env: &mut HashMap<String, String>) {
|
||||
// Enforce proxying for child processes. We intentionally override existing values so
|
||||
// command-level environment cannot bypass the managed proxy endpoint.
|
||||
|
||||
@@ -70,7 +70,9 @@ pub fn blocked_header_value(reason: &str) -> &'static str {
|
||||
|
||||
pub fn blocked_message(reason: &str) -> &'static str {
|
||||
match reason {
|
||||
REASON_NOT_ALLOWED => "Codex blocked this request: domain not in allowlist.",
|
||||
REASON_NOT_ALLOWED => {
|
||||
"Codex blocked this request: domain not in allowlist (this is not a denylist block)."
|
||||
}
|
||||
REASON_NOT_ALLOWED_LOCAL => {
|
||||
"Codex blocked this request: local/private addresses not allowed."
|
||||
}
|
||||
@@ -160,7 +162,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
message,
|
||||
r#"CODEX_NETWORK_POLICY_DECISION {"decision":"deny","reason":"not_allowed","source":"baseline_policy","protocol":"http","host":"api.example.com","port":80}
|
||||
Codex blocked this request: domain not in allowlist."#
|
||||
Codex blocked this request: domain not in allowlist (this is not a denylist block)."#
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use async_trait::async_trait;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use globset::GlobSet;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
use std::net::IpAddr;
|
||||
@@ -26,6 +27,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use time::OffsetDateTime;
|
||||
use tokio::net::lookup_host;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::time::timeout;
|
||||
use tracing::info;
|
||||
@@ -129,6 +131,7 @@ pub trait ConfigReloader: Send + Sync {
|
||||
pub struct NetworkProxyState {
|
||||
state: Arc<RwLock<ConfigState>>,
|
||||
reloader: Arc<dyn ConfigReloader>,
|
||||
temporary_allowed_hosts: Arc<Mutex<HashMap<String, usize>>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for NetworkProxyState {
|
||||
@@ -144,6 +147,7 @@ impl Clone for NetworkProxyState {
|
||||
Self {
|
||||
state: self.state.clone(),
|
||||
reloader: self.reloader.clone(),
|
||||
temporary_allowed_hosts: self.temporary_allowed_hosts.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,6 +157,31 @@ impl NetworkProxyState {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(state)),
|
||||
reloader,
|
||||
temporary_allowed_hosts: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Temporarily allow a host for one retried command execution.
|
||||
///
|
||||
/// This does not persist to config and is revoked explicitly by the caller.
|
||||
pub async fn grant_temporary_allowed_host(&self, host: &str) -> Option<String> {
|
||||
let host = Host::parse(host).ok()?.as_str().to_string();
|
||||
let mut guard = self.temporary_allowed_hosts.lock().await;
|
||||
let count = guard.entry(host.clone()).or_default();
|
||||
*count = count.saturating_add(1);
|
||||
Some(host)
|
||||
}
|
||||
|
||||
/// Revoke a previously granted temporary host allowance.
|
||||
pub async fn revoke_temporary_allowed_host(&self, host: &str) {
|
||||
let mut guard = self.temporary_allowed_hosts.lock().await;
|
||||
let Some(count) = guard.get_mut(host) else {
|
||||
return;
|
||||
};
|
||||
if *count <= 1 {
|
||||
guard.remove(host);
|
||||
} else {
|
||||
*count -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,7 +296,14 @@ impl NetworkProxyState {
|
||||
}
|
||||
}
|
||||
|
||||
if allowed_domains_empty || !is_allowlisted {
|
||||
let is_temporarily_allowed = {
|
||||
let guard = self.temporary_allowed_hosts.lock().await;
|
||||
guard.contains_key(host_str)
|
||||
};
|
||||
|
||||
if is_temporarily_allowed {
|
||||
Ok(HostBlockDecision::Allowed)
|
||||
} else if allowed_domains_empty || !is_allowlisted {
|
||||
Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed))
|
||||
} else {
|
||||
Ok(HostBlockDecision::Allowed)
|
||||
@@ -566,6 +602,54 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn temporary_host_allowance_allows_host_until_revoked() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["*.openai.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("google.com", 80).await.unwrap(),
|
||||
HostBlockDecision::Blocked(HostBlockReason::NotAllowed)
|
||||
);
|
||||
|
||||
let granted_host = state
|
||||
.grant_temporary_allowed_host("google.com")
|
||||
.await
|
||||
.expect("host should parse");
|
||||
assert_eq!(
|
||||
state.host_blocked("google.com", 80).await.unwrap(),
|
||||
HostBlockDecision::Allowed
|
||||
);
|
||||
|
||||
state.revoke_temporary_allowed_host(&granted_host).await;
|
||||
assert_eq!(
|
||||
state.host_blocked("google.com", 80).await.unwrap(),
|
||||
HostBlockDecision::Blocked(HostBlockReason::NotAllowed)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn temporary_host_allowance_does_not_bypass_denylist() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["*".to_string()],
|
||||
denied_domains: vec!["google.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
|
||||
let granted_host = state
|
||||
.grant_temporary_allowed_host("google.com")
|
||||
.await
|
||||
.expect("host should parse");
|
||||
assert_eq!(
|
||||
state.host_blocked("google.com", 80).await.unwrap(),
|
||||
HostBlockDecision::Blocked(HostBlockReason::Denied)
|
||||
);
|
||||
|
||||
state.revoke_temporary_allowed_host(&granted_host).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_blocked_subdomain_wildcards_exclude_apex() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
|
||||
@@ -37,6 +37,19 @@ impl From<Vec<String>> for ExecPolicyAmendment {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NetworkApprovalProtocol {
|
||||
Http,
|
||||
Https,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
pub struct NetworkApprovalContext {
|
||||
pub host: String,
|
||||
pub protocol: NetworkApprovalProtocol,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct ExecApprovalRequestEvent {
|
||||
/// Identifier for the associated exec call, if available.
|
||||
@@ -52,6 +65,10 @@ pub struct ExecApprovalRequestEvent {
|
||||
/// Optional human-readable reason for the approval (e.g. retry without sandbox).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
/// Optional network context for a blocked request that can be approved.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub network_approval_context: Option<NetworkApprovalContext>,
|
||||
/// Proposed execpolicy amendment that can be applied to allow future runs.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
|
||||
@@ -53,6 +53,8 @@ pub use crate::approvals::ApplyPatchApprovalRequestEvent;
|
||||
pub use crate::approvals::ElicitationAction;
|
||||
pub use crate::approvals::ExecApprovalRequestEvent;
|
||||
pub use crate::approvals::ExecPolicyAmendment;
|
||||
pub use crate::approvals::NetworkApprovalContext;
|
||||
pub use crate::approvals::NetworkApprovalProtocol;
|
||||
pub use crate::request_user_input::RequestUserInputEvent;
|
||||
|
||||
/// Open/close tags for special user-input blocks. Used across crates to avoid
|
||||
|
||||
@@ -20,6 +20,7 @@ use codex_core::features::Features;
|
||||
use codex_core::protocol::ElicitationAction;
|
||||
use codex_core::protocol::ExecPolicyAmendment;
|
||||
use codex_core::protocol::FileChange;
|
||||
use codex_core::protocol::NetworkApprovalContext;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::ReviewDecision;
|
||||
use codex_protocol::mcp::RequestId;
|
||||
@@ -42,6 +43,7 @@ pub(crate) enum ApprovalRequest {
|
||||
id: String,
|
||||
command: Vec<String>,
|
||||
reason: Option<String>,
|
||||
network_approval_context: Option<NetworkApprovalContext>,
|
||||
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
||||
},
|
||||
ApplyPatch {
|
||||
@@ -108,11 +110,23 @@ impl ApprovalOverlay {
|
||||
) -> (Vec<ApprovalOption>, SelectionViewParams) {
|
||||
let (options, title) = match &variant {
|
||||
ApprovalVariant::Exec {
|
||||
network_approval_context,
|
||||
proposed_execpolicy_amendment,
|
||||
..
|
||||
} => (
|
||||
exec_options(proposed_execpolicy_amendment.clone()),
|
||||
"Would you like to run the following command?".to_string(),
|
||||
exec_options(
|
||||
proposed_execpolicy_amendment.clone(),
|
||||
network_approval_context.as_ref(),
|
||||
),
|
||||
network_approval_context.as_ref().map_or_else(
|
||||
|| "Would you like to run the following command?".to_string(),
|
||||
|network_approval_context| {
|
||||
format!(
|
||||
"Do you want to approve access to \"{}\"?",
|
||||
network_approval_context.host
|
||||
)
|
||||
},
|
||||
),
|
||||
),
|
||||
ApprovalVariant::ApplyPatch { .. } => (
|
||||
patch_options(),
|
||||
@@ -342,6 +356,7 @@ impl From<ApprovalRequest> for ApprovalRequestState {
|
||||
id,
|
||||
command,
|
||||
reason,
|
||||
network_approval_context,
|
||||
proposed_execpolicy_amendment,
|
||||
} => {
|
||||
let mut header: Vec<Line<'static>> = Vec::new();
|
||||
@@ -359,6 +374,7 @@ impl From<ApprovalRequest> for ApprovalRequestState {
|
||||
variant: ApprovalVariant::Exec {
|
||||
id,
|
||||
command,
|
||||
network_approval_context,
|
||||
proposed_execpolicy_amendment,
|
||||
},
|
||||
header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })),
|
||||
@@ -414,6 +430,7 @@ enum ApprovalVariant {
|
||||
Exec {
|
||||
id: String,
|
||||
command: Vec<String>,
|
||||
network_approval_context: Option<NetworkApprovalContext>,
|
||||
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
||||
},
|
||||
ApplyPatch {
|
||||
@@ -447,7 +464,27 @@ impl ApprovalOption {
|
||||
}
|
||||
}
|
||||
|
||||
fn exec_options(proposed_execpolicy_amendment: Option<ExecPolicyAmendment>) -> Vec<ApprovalOption> {
|
||||
fn exec_options(
|
||||
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
||||
network_approval_context: Option<&NetworkApprovalContext>,
|
||||
) -> Vec<ApprovalOption> {
|
||||
if network_approval_context.is_some() {
|
||||
return vec![
|
||||
ApprovalOption {
|
||||
label: "Yes, proceed".to_string(),
|
||||
decision: ApprovalDecision::Review(ReviewDecision::Approved),
|
||||
display_shortcut: None,
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
|
||||
},
|
||||
ApprovalOption {
|
||||
label: "No, and tell Codex what to do differently".to_string(),
|
||||
decision: ApprovalDecision::Review(ReviewDecision::Abort),
|
||||
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
|
||||
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
vec![ApprovalOption {
|
||||
label: "Yes, proceed".to_string(),
|
||||
decision: ApprovalDecision::Review(ReviewDecision::Approved),
|
||||
@@ -531,6 +568,7 @@ fn elicitation_options() -> Vec<ApprovalOption> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use codex_core::protocol::NetworkApprovalProtocol;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
@@ -539,6 +577,7 @@ mod tests {
|
||||
id: "test".to_string(),
|
||||
command: vec!["echo".to_string(), "hi".to_string()],
|
||||
reason: Some("reason".to_string()),
|
||||
network_approval_context: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
}
|
||||
@@ -581,6 +620,7 @@ mod tests {
|
||||
id: "test".to_string(),
|
||||
command: vec!["echo".to_string()],
|
||||
reason: None,
|
||||
network_approval_context: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
|
||||
"echo".to_string(),
|
||||
])),
|
||||
@@ -619,6 +659,7 @@ mod tests {
|
||||
id: "test".into(),
|
||||
command,
|
||||
reason: None,
|
||||
network_approval_context: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
};
|
||||
|
||||
@@ -641,6 +682,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_exec_options_use_expected_labels_and_hide_execpolicy_amendment() {
|
||||
let network_context = NetworkApprovalContext {
|
||||
host: "example.com".to_string(),
|
||||
protocol: NetworkApprovalProtocol::Https,
|
||||
};
|
||||
let options = exec_options(
|
||||
Some(ExecPolicyAmendment::new(vec!["curl".to_string()])),
|
||||
Some(&network_context),
|
||||
);
|
||||
|
||||
let labels: Vec<String> = options.into_iter().map(|option| option.label).collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
vec![
|
||||
"Yes, proceed".to_string(),
|
||||
"No, and tell Codex what to do differently".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_exec_prompt_title_includes_host() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
let exec_request = ApprovalRequest::Exec {
|
||||
id: "test".into(),
|
||||
command: vec!["curl".into(), "https://example.com".into()],
|
||||
reason: Some("network request blocked".into()),
|
||||
network_approval_context: Some(NetworkApprovalContext {
|
||||
host: "example.com".to_string(),
|
||||
protocol: NetworkApprovalProtocol::Https,
|
||||
}),
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec!["curl".into()])),
|
||||
};
|
||||
|
||||
let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults());
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 100, view.desired_height(100)));
|
||||
view.render(Rect::new(0, 0, 100, view.desired_height(100)), &mut buf);
|
||||
|
||||
let rendered: Vec<String> = (0..buf.area.height)
|
||||
.map(|row| {
|
||||
(0..buf.area.width)
|
||||
.map(|col| buf[(col, row)].symbol().to_string())
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
rendered
|
||||
.iter()
|
||||
.any(|line| line.contains("Do you want to approve access to \"example.com\"?")),
|
||||
"expected network title to include host, got {rendered:?}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.iter().any(|line| line.contains("don't ask again")),
|
||||
"network prompt should not show execpolicy option, got {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_history_cell_wraps_with_two_space_indent() {
|
||||
let command = vec![
|
||||
|
||||
@@ -989,6 +989,7 @@ mod tests {
|
||||
id: "1".to_string(),
|
||||
command: vec!["echo".into(), "ok".into()],
|
||||
reason: None,
|
||||
network_approval_context: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2393,6 +2393,7 @@ impl ChatWidget {
|
||||
id: ev.call_id,
|
||||
command: ev.command,
|
||||
reason: ev.reason,
|
||||
network_approval_context: ev.network_approval_context,
|
||||
proposed_execpolicy_amendment: ev.proposed_execpolicy_amendment,
|
||||
};
|
||||
self.bottom_pane
|
||||
|
||||
@@ -1934,6 +1934,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() {
|
||||
"this is a test reason such as one that would be produced by the model".into(),
|
||||
),
|
||||
proposed_execpolicy_amendment: None,
|
||||
network_approval_context: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -1978,6 +1979,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
|
||||
"this is a test reason such as one that would be produced by the model".into(),
|
||||
),
|
||||
proposed_execpolicy_amendment: None,
|
||||
network_approval_context: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -2028,6 +2030,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
network_approval_context: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -4614,6 +4617,7 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> {
|
||||
"hello".into(),
|
||||
"world".into(),
|
||||
])),
|
||||
network_approval_context: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -4667,6 +4671,7 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> {
|
||||
"hello".into(),
|
||||
"world".into(),
|
||||
])),
|
||||
network_approval_context: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -4707,6 +4712,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot()
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
|
||||
network_approval_context: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -5066,6 +5072,7 @@ async fn status_widget_and_approval_modal_snapshot() {
|
||||
"echo".into(),
|
||||
"hello world".into(),
|
||||
])),
|
||||
network_approval_context: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
|
||||
Reference in New Issue
Block a user