Compare commits

...

32 Commits

Author SHA1 Message Date
kevin zhao
e44facebde maybefix 2025-11-20 01:41:14 +00:00
kevin zhao
a1c9907b6d fix clippy 2025-11-20 00:30:07 +00:00
kevin zhao
44eb85afbf [draft] first pass at tui integration 2025-11-19 16:15:41 -08:00
kevin zhao
f4ccc759b0 fixing compile error 2025-11-19 16:14:07 -08:00
kevin zhao
09bd2732ad fmt 2025-11-19 16:12:02 -08:00
kevin zhao
70ffb8d1bc using Policy::empty() instead 2025-11-19 16:12:02 -08:00
kevin zhao
59efe00241 crashing & better error handling 2025-11-19 16:12:02 -08:00
kevin zhao
20cfc4c9d2 another note on readme 2025-11-19 16:12:01 -08:00
kevin zhao
542fcbcebc improving readme 2025-11-19 16:12:01 -08:00
kevin zhao
7c65c9d546 update readmes pt.2 2025-11-19 16:12:01 -08:00
kevin zhao
dcfed30f50 update READMEs 2025-11-19 16:12:01 -08:00
zhao-oai
e2a4c09fd8 /// for docstring
Co-authored-by: Michael Bolin <mbolin@openai.com>
2025-11-19 16:12:01 -08:00
kevin zhao
267af1220d do not ensure dir and default.codexpolicy 2025-11-19 16:12:01 -08:00
kevin zhao
a167bc630a with_escalated_permissions -> enum 2025-11-19 16:12:01 -08:00
kevin zhao
4560a7727e adding docstrings for ApprovalRequirement 2025-11-19 16:12:01 -08:00
kevin zhao
fff04c53d5 using async 2025-11-19 16:12:01 -08:00
kevin zhao
00c454ca82 load execpolicy from codex_home/policy and default to empty policy 2025-11-19 16:12:01 -08:00
zhao-oai
e74034ca0c running test single threaded
Co-authored-by: Michael Bolin <mbolin@openai.com>
2025-11-19 16:12:00 -08:00
kevin zhao
d9c5d2f893 enabling execpolicy by default 2025-11-19 16:12:00 -08:00
kevin zhao
ed9dd9dfa4 update debug message 2025-11-19 16:12:00 -08:00
kevin zhao
391ee23e62 addressing comments 2025-11-19 16:12:00 -08:00
kevin zhao
efe870009d remove unused import 2025-11-19 16:12:00 -08:00
kevin zhao
b588849f0a fix rebase error 2025-11-19 16:12:00 -08:00
kevin zhao
e5b6e2b0c6 undo diff 2025-11-19 16:12:00 -08:00
kevin zhao
05c392abee exec_policy 2025-11-19 16:12:00 -08:00
kevin zhao
d0c67b5bf1 rename test file 2025-11-19 16:12:00 -08:00
kevin zhao
3b02828ab6 update comment 2025-11-19 16:12:00 -08:00
kevin zhao
b1ed2bc7b8 update tracing:: message 2025-11-19 16:12:00 -08:00
kevin zhao
f712e0a4a0 execpolicy2 -> execpolicy 2025-11-19 16:11:59 -08:00
kevin zhao
8f9ad7e509 execpolicy2 core integration 2025-11-19 16:11:59 -08:00
kevin zhao
8510d72940 precompute approval_requirement 2025-11-19 16:11:59 -08:00
kevin zhao
dabd7e14db execpolicy2 core integration
fix PR

undo keyring store
2025-11-19 16:11:59 -08:00
44 changed files with 1226 additions and 128 deletions

View File

@@ -69,6 +69,39 @@ Codex can access MCP servers. To configure them, refer to the [config docs](./do
Codex CLI supports a rich set of configuration options, with preferences stored in `~/.codex/config.toml`. For full configuration options, see [Configuration](./docs/config.md).
### Execpolicy quickstart
Codex can enforce your own rules-based execution policy before it runs shell commands.
1. Create a policy directory: `mkdir -p ~/.codex/policy`.
2. Create one or more `.codexpolicy` files into that folder. Codex automatically loads every `.codexpolicy` file in there on startup.
3. Write `prefix_rule` entries to describe the commands you want to allow, prompt, or block:
```starlark
prefix_rule(
pattern = ["git", ["push", "fetch"]],
decision = "prompt", # allow | prompt | forbidden
match = [["git", "push", "origin", "main"]], # examples that must match
not_match = [["git", "status"]], # examples that must not match
)
```
- `pattern` is a list of shell tokens, evaluated from left to right; wrap tokens in a nested list to express alternatives (e.g., match both `push` and `fetch`).
- `decision` sets the severity; Codex picks the strictest decision when multiple rules match.
- `match` and `not_match` act as (optional) unit tests. Codex validates them when it loads your policy, so you get feedback if an example has unexpected behavior.
In this example rule, if Codex wants to run commands with the prefix `git push` or `git fetch`, it will first ask for user approval.
Note: If Codex wants to run a command that matches with multiple rules, it will use the strictest decision among the matched rules (forbidden > prompt > allow).
Use the [`execpolicy2` CLI](./codex-rs/execpolicy2/README.md) to preview decisions before you save a rule:
```shell
cargo run -p codex-execpolicy2 -- check --policy ~/.codex/policy/default.codexpolicy git push origin main
```
Pass multiple `--policy` flags to test how several files combine. See the [`codex-rs/execpolicy2` README](./codex-rs/execpolicy2/README.md) for a more detailed walkthrough of the available syntax.
---
### Docs & FAQ

2
codex-rs/Cargo.lock generated
View File

@@ -1086,6 +1086,7 @@ dependencies = [
"codex-apply-patch",
"codex-arg0",
"codex-async-utils",
"codex-execpolicy2",
"codex-file-search",
"codex-git",
"codex-keyring-store",
@@ -1237,6 +1238,7 @@ dependencies = [
"serde_json",
"shlex",
"starlark",
"tempfile",
"thiserror 2.0.17",
]

View File

@@ -67,6 +67,7 @@ codex-chatgpt = { path = "chatgpt" }
codex-common = { path = "common" }
codex-core = { path = "core" }
codex-exec = { path = "exec" }
codex-execpolicy2 = { path = "execpolicy2" }
codex-feedback = { path = "feedback" }
codex-file-search = { path = "file-search" }
codex-git = { path = "utils/git" }

View File

@@ -614,6 +614,7 @@ mod tests {
parsed_cmd: vec![ParsedCommand::Unknown {
cmd: "echo hello".to_string(),
}],
allow_prefix: None,
};
let request = ServerRequest::ExecCommandApproval {
request_id: RequestId::Integer(7),

View File

@@ -228,6 +228,7 @@ pub struct ExecCommandApprovalParams {
pub reason: Option<String>,
pub risk: Option<SandboxCommandAssessment>,
pub parsed_cmd: Vec<ParsedCommand>,
pub allow_prefix: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]

View File

@@ -960,6 +960,7 @@ pub struct CommandExecutionRequestApprovalParams {
pub reason: Option<String>,
/// Optional model-provided risk assessment describing the blocked command.
pub risk: Option<SandboxCommandAssessment>,
pub allow_prefix: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -969,6 +970,9 @@ pub struct CommandExecutionRequestAcceptSettings {
/// If true, automatically approve this command for the duration of the session.
#[serde(default)]
pub for_session: bool,
/// If true, persist an allow rule for the provided prefix.
#[serde(default)]
pub allow_prefix_rule: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]

View File

@@ -696,6 +696,7 @@ impl CodexClient {
item_id,
reason,
risk,
allow_prefix,
} = params;
println!(
@@ -707,10 +708,16 @@ impl CodexClient {
if let Some(risk) = risk.as_ref() {
println!("< risk assessment: {risk:?}");
}
if let Some(prefix) = allow_prefix.as_ref() {
println!("< allow prefix: {prefix:?}");
}
let response = CommandExecutionRequestApprovalResponse {
decision: ApprovalDecision::Accept,
accept_settings: Some(CommandExecutionRequestAcceptSettings { for_session: false }),
accept_settings: Some(CommandExecutionRequestAcceptSettings {
for_session: false,
allow_prefix_rule: allow_prefix.is_some(),
}),
};
self.send_server_request_response(request_id, &response)?;
println!("< approved commandExecution request for item {item_id}");

View File

@@ -96,6 +96,7 @@ pub(crate) async fn apply_bespoke_event_handling(
reason,
risk,
parsed_cmd,
allow_prefix,
}) => match api_version {
ApiVersion::V1 => {
let params = ExecCommandApprovalParams {
@@ -106,6 +107,7 @@ pub(crate) async fn apply_bespoke_event_handling(
reason,
risk,
parsed_cmd,
allow_prefix,
};
let rx = outgoing
.send_request(ServerRequestPayload::ExecCommandApproval(params))
@@ -123,6 +125,7 @@ pub(crate) async fn apply_bespoke_event_handling(
item_id: call_id.clone(),
reason,
risk: risk.map(V2SandboxCommandAssessment::from),
allow_prefix,
};
let rx = outgoing
.send_request(ServerRequestPayload::CommandExecutionRequestApproval(
@@ -540,13 +543,20 @@ async fn on_command_execution_request_approval_response(
accept_settings,
} = response;
let decision = match (decision, accept_settings) {
(ApprovalDecision::Accept, Some(settings)) if settings.for_session => {
ReviewDecision::ApprovedForSession
}
(ApprovalDecision::Accept, _) => ReviewDecision::Approved,
(ApprovalDecision::Decline, _) => ReviewDecision::Denied,
(ApprovalDecision::Cancel, _) => ReviewDecision::Abort,
let allow_prefix_rule = accept_settings
.as_ref()
.is_some_and(|settings| settings.allow_prefix_rule);
let for_session = accept_settings
.as_ref()
.map(|settings| settings.for_session)
.unwrap_or(false);
let decision = match (decision, allow_prefix_rule, for_session) {
(ApprovalDecision::Accept, true, _) => ReviewDecision::ApprovedAllowPrefix,
(ApprovalDecision::Accept, false, true) => ReviewDecision::ApprovedForSession,
(ApprovalDecision::Accept, false, false) => ReviewDecision::Approved,
(ApprovalDecision::Decline, _, _) => ReviewDecision::Denied,
(ApprovalDecision::Cancel, _, _) => ReviewDecision::Abort,
};
if let Err(err) = conversation
.submit(Op::ExecApproval {

View File

@@ -278,6 +278,11 @@ async fn test_send_user_turn_changes_approval_policy_behavior() -> Result<()> {
parsed_cmd: vec![ParsedCommand::Unknown {
cmd: "python3 -c 'print(42)'".to_string()
}],
allow_prefix: Some(vec![
"python3".to_string(),
"-c".to_string(),
"print(42)".to_string(),
]),
},
params
);

View File

@@ -22,6 +22,7 @@ chrono = { workspace = true, features = ["serde"] }
codex-app-server-protocol = { workspace = true }
codex-apply-patch = { workspace = true }
codex-async-utils = { workspace = true }
codex-execpolicy2 = { workspace = true }
codex-file-search = { workspace = true }
codex-git = { workspace = true }
codex-keyring-store = { workspace = true }

View File

@@ -70,7 +70,9 @@ pub(crate) async fn apply_patch(
)
.await;
match rx_approve.await.unwrap_or_default() {
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {
ReviewDecision::Approved
| ReviewDecision::ApprovedAllowPrefix
| ReviewDecision::ApprovedForSession => {
InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec {
action,
user_explicitly_approved_this_action: true,

View File

@@ -121,6 +121,7 @@ use crate::user_instructions::UserInstructions;
use crate::user_notification::UserNotification;
use crate::util::backoff;
use codex_async_utils::OrCancelExt;
use codex_execpolicy2::Policy as ExecPolicy;
use codex_otel::otel_event_manager::OtelEventManager;
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
@@ -167,6 +168,10 @@ impl Codex {
let user_instructions = get_user_instructions(&config).await;
let exec_policy = crate::exec_policy::exec_policy_for(&config.features, &config.codex_home)
.await
.map_err(|err| CodexErr::Fatal(format!("failed to load execpolicy: {err}")))?;
let config = Arc::new(config);
let session_configuration = SessionConfiguration {
@@ -183,6 +188,7 @@ impl Codex {
cwd: config.cwd.clone(),
original_config_do_not_use: Arc::clone(&config),
features: config.features.clone(),
exec_policy,
session_source,
};
@@ -280,6 +286,7 @@ pub(crate) struct TurnContext {
pub(crate) final_output_json_schema: Option<Value>,
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
pub(crate) tool_call_gate: Arc<ReadinessFlag>,
pub(crate) exec_policy: Arc<ExecPolicy>,
pub(crate) truncation_policy: TruncationPolicy,
}
@@ -336,6 +343,8 @@ pub(crate) struct SessionConfiguration {
/// Set of feature flags for this session
features: Features,
/// Execpolicy policy, applied only when enabled by feature flag.
exec_policy: Arc<ExecPolicy>,
// TODO(pakrym): Remove config from here
original_config_do_not_use: Arc<Config>,
@@ -436,6 +445,7 @@ impl Session {
final_output_json_schema: None,
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
tool_call_gate: Arc::new(ReadinessFlag::new()),
exec_policy: session_configuration.exec_policy.clone(),
truncation_policy: TruncationPolicy::new(&per_turn_config),
}
}
@@ -564,6 +574,7 @@ impl Session {
auth_manager: Arc::clone(&auth_manager),
otel_event_manager,
tool_approvals: Mutex::new(ApprovalStore::default()),
exec_policy_overrides: Mutex::new(Vec::new()),
};
let sess = Arc::new(Session {
@@ -836,11 +847,35 @@ impl Session {
.await
}
pub(crate) async fn remember_allowed_prefix(&self, prefix: &[String]) {
{
let mut overrides = self.services.exec_policy_overrides.lock().await;
if overrides.iter().any(|existing| existing == prefix) {
return;
}
overrides.push(prefix.to_vec());
}
let codex_home = {
let state = self.state.lock().await;
state
.session_configuration
.original_config_do_not_use
.codex_home
.clone()
};
if let Err(err) = crate::exec_policy::persist_allow_rule(&codex_home, prefix).await {
warn!("failed to persist execpolicy allow rule: {err}");
}
}
/// Emit an exec approval request event and await the user's decision.
///
/// The request is keyed by `sub_id`/`call_id` so matching responses are delivered
/// to the correct in-flight turn. If the task is aborted, this returns the
/// default `ReviewDecision` (`Denied`).
#[allow(clippy::too_many_arguments)]
pub async fn request_command_approval(
&self,
turn_context: &TurnContext,
@@ -849,6 +884,7 @@ impl Session {
cwd: PathBuf,
reason: Option<String>,
risk: Option<SandboxCommandAssessment>,
allow_prefix: Option<Vec<String>>,
) -> ReviewDecision {
let sub_id = turn_context.sub_id.clone();
// Add the tx_approve callback to the map before sending the request.
@@ -877,6 +913,7 @@ impl Session {
reason,
risk,
parsed_cmd,
allow_prefix,
});
self.send_event(turn_context, event).await;
rx_approve.await.unwrap_or_default()
@@ -1789,6 +1826,7 @@ async fn spawn_review_thread(
final_output_json_schema: None,
codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(),
tool_call_gate: Arc::new(ReadinessFlag::new()),
exec_policy: parent_turn_context.exec_policy.clone(),
truncation_policy: TruncationPolicy::new(&per_turn_config),
};
@@ -2608,6 +2646,7 @@ mod tests {
cwd: config.cwd.clone(),
original_config_do_not_use: Arc::clone(&config),
features: Features::default(),
exec_policy: Arc::new(codex_execpolicy2::Policy::empty()),
session_source: SessionSource::Exec,
};
@@ -2624,6 +2663,7 @@ mod tests {
auth_manager: Arc::clone(&auth_manager),
otel_event_manager: otel_event_manager.clone(),
tool_approvals: Mutex::new(ApprovalStore::default()),
exec_policy_overrides: Mutex::new(Vec::new()),
};
let turn_context = Session::make_turn_context(
@@ -2685,6 +2725,7 @@ mod tests {
cwd: config.cwd.clone(),
original_config_do_not_use: Arc::clone(&config),
features: Features::default(),
exec_policy: Arc::new(codex_execpolicy2::Policy::empty()),
session_source: SessionSource::Exec,
};
@@ -2701,6 +2742,7 @@ mod tests {
auth_manager: Arc::clone(&auth_manager),
otel_event_manager: otel_event_manager.clone(),
tool_approvals: Mutex::new(ApprovalStore::default()),
exec_policy_overrides: Mutex::new(Vec::new()),
};
let turn_context = Arc::new(Session::make_turn_context(

View File

@@ -235,6 +235,7 @@ async fn handle_exec_approval(
event.cwd,
event.reason,
event.risk,
event.allow_prefix,
);
let decision = await_approval_with_cancel(
approval_fut,

View File

@@ -1,6 +1,8 @@
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use crate::sandboxing::SandboxPermissions;
use crate::bash::parse_shell_lc_plain_commands;
use crate::is_safe_command::is_known_safe_command;
@@ -8,7 +10,7 @@ pub fn requires_initial_appoval(
policy: AskForApproval,
sandbox_policy: &SandboxPolicy,
command: &[String],
with_escalated_permissions: bool,
sandbox_permissions: SandboxPermissions,
) -> bool {
if is_known_safe_command(command) {
return false;
@@ -24,8 +26,7 @@ pub fn requires_initial_appoval(
// In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for
// nonescalated, nondangerous commands — let the sandbox enforce
// restrictions (e.g., block network/write) without a user prompt.
let wants_escalation: bool = with_escalated_permissions;
if wants_escalation {
if sandbox_permissions.requires_escalated_permissions() {
return true;
}
command_might_be_dangerous(command)

View File

@@ -0,0 +1,530 @@
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use crate::command_safety::is_dangerous_command::requires_initial_appoval;
use codex_execpolicy2::Decision;
use codex_execpolicy2::Evaluation;
use codex_execpolicy2::Policy;
use codex_execpolicy2::PolicyParser;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use thiserror::Error;
use tokio::fs;
use crate::bash::parse_shell_lc_plain_commands;
use crate::features::Feature;
use crate::features::Features;
use crate::sandboxing::SandboxPermissions;
use crate::tools::sandboxing::ApprovalRequirement;
const FORBIDDEN_REASON: &str = "execpolicy forbids this command";
const PROMPT_REASON: &str = "execpolicy requires approval for this command";
const POLICY_DIR_NAME: &str = "policy";
const POLICY_EXTENSION: &str = "codexpolicy";
const DEFAULT_POLICY_FILE_NAME: &str = "default.codexpolicy";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CommandApproval {
pub requirement: ApprovalRequirement,
pub allow_prefix: Option<Vec<String>>,
}
#[derive(Debug, Error)]
pub enum ExecPolicyError {
#[error("failed to read execpolicy files from {dir}: {source}")]
ReadDir {
dir: PathBuf,
source: std::io::Error,
},
#[error("failed to read execpolicy file {path}: {source}")]
ReadFile {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to parse execpolicy file {path}: {source}")]
ParsePolicy {
path: String,
source: codex_execpolicy2::Error,
},
}
#[derive(Debug, Error)]
pub enum ExecPolicyPersistError {
#[error("failed to persist allow rule in {path}: {source}")]
WritePolicy {
path: PathBuf,
#[source]
source: codex_execpolicy2::WritePolicyError,
},
#[error("failed to persist allow rule: {source}")]
Join {
#[from]
source: tokio::task::JoinError,
},
}
pub(crate) async fn exec_policy_for(
features: &Features,
codex_home: &Path,
) -> Result<Arc<Policy>, ExecPolicyError> {
if !features.enabled(Feature::ExecPolicy) {
return Ok(Arc::new(Policy::empty()));
}
let policy_dir = codex_home.join(POLICY_DIR_NAME);
let policy_paths = collect_policy_files(&policy_dir).await?;
let mut parser = PolicyParser::new();
for policy_path in &policy_paths {
let contents =
fs::read_to_string(policy_path)
.await
.map_err(|source| ExecPolicyError::ReadFile {
path: policy_path.clone(),
source,
})?;
let identifier = policy_path.to_string_lossy().to_string();
parser
.parse(&identifier, &contents)
.map_err(|source| ExecPolicyError::ParsePolicy {
path: identifier,
source,
})?;
}
let policy = Arc::new(parser.build());
tracing::debug!(
"loaded execpolicy from {} files in {}",
policy_paths.len(),
policy_dir.display()
);
Ok(policy)
}
fn evaluate_with_policy(
policy: &Policy,
commands: &[Vec<String>],
approval_policy: AskForApproval,
) -> (Evaluation, Option<ApprovalRequirement>) {
let evaluation = policy.check_multiple(commands.iter());
let requirement = match evaluation {
Evaluation::Match { decision, .. } => match decision {
Decision::Forbidden => Some(ApprovalRequirement::Forbidden {
reason: FORBIDDEN_REASON.to_string(),
}),
Decision::Prompt => {
let reason = PROMPT_REASON.to_string();
if matches!(approval_policy, AskForApproval::Never) {
Some(ApprovalRequirement::Forbidden { reason })
} else {
Some(ApprovalRequirement::NeedsApproval {
reason: Some(reason),
})
}
}
Decision::Allow => Some(ApprovalRequirement::Skip),
},
Evaluation::NoMatch => None,
};
(evaluation, requirement)
}
fn matches_additional_allow_prefix(
commands: &[Vec<String>],
additional_allow_prefixes: &[Vec<String>],
) -> bool {
commands.iter().any(|command| {
additional_allow_prefixes
.iter()
.any(|prefix| command.starts_with(prefix))
})
}
pub(crate) fn create_approval_requirement_for_command(
policy: &Policy,
additional_allow_prefixes: &[Vec<String>],
command: &[String],
approval_policy: AskForApproval,
sandbox_policy: &SandboxPolicy,
sandbox_permissions: SandboxPermissions,
) -> CommandApproval {
let commands = parse_shell_lc_plain_commands(command).unwrap_or_else(|| vec![command.to_vec()]);
if matches_additional_allow_prefix(&commands, additional_allow_prefixes) {
return CommandApproval {
requirement: ApprovalRequirement::Skip,
allow_prefix: None,
};
}
let (evaluation, requirement) = evaluate_with_policy(policy, &commands, approval_policy);
if let Some(requirement) = requirement {
return CommandApproval {
requirement,
allow_prefix: None,
};
}
let requirement = if requires_initial_appoval(
approval_policy,
sandbox_policy,
command,
sandbox_permissions,
) {
ApprovalRequirement::NeedsApproval { reason: None }
} else {
ApprovalRequirement::Skip
};
let allow_prefix = commands.first().and_then(|first| {
(commands.len() == 1
&& matches!(&evaluation, Evaluation::NoMatch)
&& matches!(requirement, ApprovalRequirement::NeedsApproval { .. }))
.then_some(first.clone())
});
CommandApproval {
requirement,
allow_prefix,
}
}
async fn collect_policy_files(dir: &Path) -> Result<Vec<PathBuf>, ExecPolicyError> {
let mut read_dir = match fs::read_dir(dir).await {
Ok(read_dir) => read_dir,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
Err(source) => {
return Err(ExecPolicyError::ReadDir {
dir: dir.to_path_buf(),
source,
});
}
};
let mut policy_paths = Vec::new();
while let Some(entry) =
read_dir
.next_entry()
.await
.map_err(|source| ExecPolicyError::ReadDir {
dir: dir.to_path_buf(),
source,
})?
{
let path = entry.path();
let file_type = entry
.file_type()
.await
.map_err(|source| ExecPolicyError::ReadDir {
dir: dir.to_path_buf(),
source,
})?;
if path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext == POLICY_EXTENSION)
&& file_type.is_file()
{
policy_paths.push(path);
}
}
policy_paths.sort();
Ok(policy_paths)
}
pub(crate) async fn persist_allow_rule(
codex_home: &Path,
prefix: &[String],
) -> Result<(), ExecPolicyPersistError> {
let policy_path = codex_home
.join(POLICY_DIR_NAME)
.join(DEFAULT_POLICY_FILE_NAME);
let prefix = prefix.to_vec();
tokio::task::spawn_blocking(move || {
codex_execpolicy2::append_prefix_rule(&policy_path, &prefix, Decision::Allow).map_err(
|source| ExecPolicyPersistError::WritePolicy {
path: policy_path,
source,
},
)
})
.await?
}
#[cfg(test)]
mod tests {
use super::*;
use crate::features::Feature;
use crate::features::Features;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use pretty_assertions::assert_eq;
use std::fs;
use tempfile::tempdir;
#[tokio::test]
async fn returns_empty_policy_when_feature_disabled() {
let mut features = Features::with_defaults();
features.disable(Feature::ExecPolicy);
let temp_dir = tempdir().expect("create temp dir");
let policy = exec_policy_for(&features, temp_dir.path())
.await
.expect("policy result");
let commands = [vec!["rm".to_string()]];
assert!(matches!(
policy.check_multiple(commands.iter()),
Evaluation::NoMatch
));
assert!(!temp_dir.path().join(POLICY_DIR_NAME).exists());
}
#[tokio::test]
async fn collect_policy_files_returns_empty_when_dir_missing() {
let temp_dir = tempdir().expect("create temp dir");
let policy_dir = temp_dir.path().join(POLICY_DIR_NAME);
let files = collect_policy_files(&policy_dir)
.await
.expect("collect policy files");
assert!(files.is_empty());
}
#[tokio::test]
async fn loads_policies_from_policy_subdirectory() {
let temp_dir = tempdir().expect("create temp dir");
let policy_dir = temp_dir.path().join(POLICY_DIR_NAME);
fs::create_dir_all(&policy_dir).expect("create policy dir");
fs::write(
policy_dir.join("deny.codexpolicy"),
r#"prefix_rule(pattern=["rm"], decision="forbidden")"#,
)
.expect("write policy file");
let policy = exec_policy_for(&Features::with_defaults(), temp_dir.path())
.await
.expect("policy result");
let command = [vec!["rm".to_string()]];
assert!(matches!(
policy.check_multiple(command.iter()),
Evaluation::Match { .. }
));
}
#[tokio::test]
async fn ignores_policies_outside_policy_dir() {
let temp_dir = tempdir().expect("create temp dir");
fs::write(
temp_dir.path().join("root.codexpolicy"),
r#"prefix_rule(pattern=["ls"], decision="prompt")"#,
)
.expect("write policy file");
let policy = exec_policy_for(&Features::with_defaults(), temp_dir.path())
.await
.expect("policy result");
let command = [vec!["ls".to_string()]];
assert!(matches!(
policy.check_multiple(command.iter()),
Evaluation::NoMatch
));
}
#[test]
fn evaluates_bash_lc_inner_commands() {
let policy_src = r#"
prefix_rule(pattern=["rm"], decision="forbidden")
"#;
let mut parser = PolicyParser::new();
parser
.parse("test.codexpolicy", policy_src)
.expect("parse policy");
let policy = parser.build();
let forbidden_script = vec![
"bash".to_string(),
"-lc".to_string(),
"rm -rf /tmp".to_string(),
];
let requirement = create_approval_requirement_for_command(
&policy,
&[],
&forbidden_script,
AskForApproval::OnRequest,
&SandboxPolicy::DangerFullAccess,
SandboxPermissions::UseDefault,
)
.requirement;
assert_eq!(
requirement,
ApprovalRequirement::Forbidden {
reason: FORBIDDEN_REASON.to_string()
}
);
}
#[test]
fn approval_requirement_prefers_execpolicy_match() {
let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#;
let mut parser = PolicyParser::new();
parser
.parse("test.codexpolicy", policy_src)
.expect("parse policy");
let policy = parser.build();
let command = vec!["rm".to_string()];
let requirement = create_approval_requirement_for_command(
&policy,
&[],
&command,
AskForApproval::OnRequest,
&SandboxPolicy::DangerFullAccess,
SandboxPermissions::UseDefault,
)
.requirement;
assert_eq!(
requirement,
ApprovalRequirement::NeedsApproval {
reason: Some(PROMPT_REASON.to_string())
}
);
}
#[test]
fn approval_requirement_respects_approval_policy() {
let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#;
let mut parser = PolicyParser::new();
parser
.parse("test.codexpolicy", policy_src)
.expect("parse policy");
let policy = parser.build();
let command = vec!["rm".to_string()];
let requirement = create_approval_requirement_for_command(
&policy,
&[],
&command,
AskForApproval::Never,
&SandboxPolicy::DangerFullAccess,
SandboxPermissions::UseDefault,
)
.requirement;
assert_eq!(
requirement,
ApprovalRequirement::Forbidden {
reason: PROMPT_REASON.to_string()
}
);
}
#[test]
fn approval_requirement_falls_back_to_heuristics() {
let command = vec!["python".to_string()];
let empty_policy = Policy::empty();
let approval = create_approval_requirement_for_command(
&empty_policy,
&[],
&command,
AskForApproval::UnlessTrusted,
&SandboxPolicy::ReadOnly,
SandboxPermissions::UseDefault,
);
assert_eq!(
approval.requirement,
ApprovalRequirement::NeedsApproval { reason: None }
);
}
#[test]
fn allow_prefix_is_exposed_for_single_command_without_policy_match() {
let command = vec!["python".to_string(), "script.py".to_string()];
let empty_policy = Policy::empty();
let approval = create_approval_requirement_for_command(
&empty_policy,
&[],
&command,
AskForApproval::UnlessTrusted,
&SandboxPolicy::ReadOnly,
SandboxPermissions::UseDefault,
);
assert_eq!(approval.allow_prefix, Some(command));
}
#[test]
fn allow_prefix_is_not_exposed_when_policy_prompts() {
let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#;
let mut parser = PolicyParser::new();
parser
.parse("test.codexpolicy", policy_src)
.expect("parse policy");
let policy = parser.build();
let command = vec!["rm".to_string()];
let approval = create_approval_requirement_for_command(
&policy,
&[],
&command,
AskForApproval::OnRequest,
&SandboxPolicy::DangerFullAccess,
SandboxPermissions::UseDefault,
);
assert!(approval.allow_prefix.is_none());
}
#[test]
fn allow_prefix_is_not_exposed_for_multi_command_scripts() {
let command = vec![
"bash".to_string(),
"-lc".to_string(),
"rm -rf /tmp && echo done".to_string(),
];
let approval = create_approval_requirement_for_command(
&Policy::empty(),
&[],
&command,
AskForApproval::OnRequest,
&SandboxPolicy::DangerFullAccess,
SandboxPermissions::UseDefault,
);
assert!(approval.allow_prefix.is_none());
}
#[test]
fn additional_allow_prefixes_skip_approval() {
let command = vec!["rg".to_string(), "--files".to_string()];
let approval = create_approval_requirement_for_command(
&Policy::empty(),
std::slice::from_ref(&command),
&command,
AskForApproval::OnRequest,
&SandboxPolicy::DangerFullAccess,
SandboxPermissions::UseDefault,
);
assert_eq!(approval.requirement, ApprovalRequirement::Skip);
}
}

View File

@@ -42,6 +42,8 @@ pub enum Feature {
ViewImageTool,
/// Allow the model to request web searches.
WebSearchRequest,
/// Gate the execpolicy enforcement for shell/unified exec.
ExecPolicy,
/// Enable the model-based risk assessments for sandboxed commands.
SandboxCommandAssessment,
/// Enable Windows sandbox (restricted token) on Windows.
@@ -297,6 +299,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: false,
},
FeatureSpec {
id: Feature::ExecPolicy,
key: "exec_policy",
stage: Stage::Experimental,
default_enabled: true,
},
FeatureSpec {
id: Feature::SandboxCommandAssessment,
key: "experimental_sandbox_command_assessment",

View File

@@ -25,6 +25,7 @@ mod environment_context;
pub mod error;
pub mod exec;
pub mod exec_env;
mod exec_policy;
pub mod features;
mod flags;
pub mod git_info;

View File

@@ -26,6 +26,28 @@ use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SandboxPermissions {
UseDefault,
RequireEscalated,
}
impl SandboxPermissions {
pub fn requires_escalated_permissions(self) -> bool {
matches!(self, SandboxPermissions::RequireEscalated)
}
}
impl From<bool> for SandboxPermissions {
fn from(with_escalated_permissions: bool) -> Self {
if with_escalated_permissions {
SandboxPermissions::RequireEscalated
} else {
SandboxPermissions::UseDefault
}
}
}
#[derive(Clone, Debug)]
pub struct CommandSpec {
pub program: String,

View File

@@ -22,4 +22,5 @@ pub(crate) struct SessionServices {
pub(crate) auth_manager: Arc<AuthManager>,
pub(crate) otel_event_manager: OtelEventManager,
pub(crate) tool_approvals: Mutex<ApprovalStore>,
pub(crate) exec_policy_overrides: Mutex<Vec<Vec<String>>>,
}

View File

@@ -9,9 +9,11 @@ use crate::apply_patch::convert_apply_patch_to_protocol;
use crate::codex::TurnContext;
use crate::exec::ExecParams;
use crate::exec_env::create_env;
use crate::exec_policy::create_approval_requirement_for_command;
use crate::function_tool::FunctionCallError;
use crate::is_safe_command::is_known_safe_command;
use crate::protocol::ExecCommandSource;
use crate::sandboxing::SandboxPermissions;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
@@ -295,6 +297,16 @@ impl ShellHandler {
let event_ctx = ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, None);
emitter.begin(event_ctx).await;
let additional_allow_prefixes = session.services.exec_policy_overrides.lock().await.clone();
let approval = create_approval_requirement_for_command(
&turn.exec_policy,
&additional_allow_prefixes,
&exec_params.command,
turn.approval_policy,
&turn.sandbox_policy,
SandboxPermissions::from(exec_params.with_escalated_permissions.unwrap_or(false)),
);
let req = ShellRequest {
command: exec_params.command.clone(),
cwd: exec_params.cwd.clone(),
@@ -302,6 +314,8 @@ impl ShellHandler {
env: exec_params.env.clone(),
with_escalated_permissions: exec_params.with_escalated_permissions,
justification: exec_params.justification.clone(),
approval_requirement: approval.requirement,
policy_allow_prefix: approval.allow_prefix,
};
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = ShellRuntime::new();

View File

@@ -11,11 +11,13 @@ use crate::error::get_error_message_ui;
use crate::exec::ExecToolCallOutput;
use crate::sandboxing::SandboxManager;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ApprovalRequirement;
use crate::tools::sandboxing::ProvidesSandboxRetryData;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::default_approval_requirement;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ReviewDecision;
@@ -49,40 +51,54 @@ impl ToolOrchestrator {
let otel_cfg = codex_otel::otel_event_manager::ToolDecisionSource::Config;
// 1) Approval
let needs_initial_approval =
tool.wants_initial_approval(req, approval_policy, &turn_ctx.sandbox_policy);
let mut already_approved = false;
if needs_initial_approval {
let mut risk = None;
if let Some(metadata) = req.sandbox_retry_data() {
risk = tool_ctx
.session
.assess_sandbox_command(turn_ctx, &tool_ctx.call_id, &metadata.command, None)
.await;
let requirement = tool.approval_requirement(req).unwrap_or_else(|| {
default_approval_requirement(approval_policy, &turn_ctx.sandbox_policy)
});
match requirement {
ApprovalRequirement::Skip => {
otel.tool_decision(otel_tn, otel_ci, ReviewDecision::Approved, otel_cfg);
}
ApprovalRequirement::Forbidden { reason } => {
return Err(ToolError::Rejected(reason));
}
ApprovalRequirement::NeedsApproval { reason } => {
let mut risk = None;
let approval_ctx = ApprovalCtx {
session: tool_ctx.session,
turn: turn_ctx,
call_id: &tool_ctx.call_id,
retry_reason: None,
risk,
};
let decision = tool.start_approval_async(req, approval_ctx).await;
otel.tool_decision(otel_tn, otel_ci, decision, otel_user.clone());
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
return Err(ToolError::Rejected("rejected by user".to_string()));
if let Some(metadata) = req.sandbox_retry_data() {
risk = tool_ctx
.session
.assess_sandbox_command(
turn_ctx,
&tool_ctx.call_id,
&metadata.command,
None,
)
.await;
}
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {}
let approval_ctx = ApprovalCtx {
session: tool_ctx.session,
turn: turn_ctx,
call_id: &tool_ctx.call_id,
retry_reason: reason,
risk,
};
let decision = tool.start_approval_async(req, approval_ctx).await;
otel.tool_decision(otel_tn, otel_ci, decision, otel_user.clone());
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
return Err(ToolError::Rejected("rejected by user".to_string()));
}
ReviewDecision::Approved
| ReviewDecision::ApprovedAllowPrefix
| ReviewDecision::ApprovedForSession => {}
}
already_approved = true;
}
already_approved = true;
} else {
otel.tool_decision(otel_tn, otel_ci, ReviewDecision::Approved, otel_cfg);
}
// 2) First attempt under the selected sandbox.
@@ -159,7 +175,9 @@ impl ToolOrchestrator {
ReviewDecision::Denied | ReviewDecision::Abort => {
return Err(ToolError::Rejected("rejected by user".to_string()));
}
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {}
ReviewDecision::Approved
| ReviewDecision::ApprovedAllowPrefix
| ReviewDecision::ApprovedForSession => {}
}
}

View File

@@ -127,6 +127,7 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
cwd,
Some(reason),
risk,
None,
)
.await
} else if user_explicitly_approved {

View File

@@ -4,13 +4,12 @@ Runtime: shell
Executes shell requests under the orchestrator: asks for approval when needed,
builds a CommandSpec, and runs it under the current SandboxAttempt.
*/
use crate::command_safety::is_dangerous_command::requires_initial_appoval;
use crate::exec::ExecToolCallOutput;
use crate::protocol::SandboxPolicy;
use crate::sandboxing::execute_env;
use crate::tools::runtimes::build_command_spec;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ApprovalRequirement;
use crate::tools::sandboxing::ProvidesSandboxRetryData;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::SandboxRetryData;
@@ -20,7 +19,6 @@ use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::with_cached_approval;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ReviewDecision;
use futures::future::BoxFuture;
use std::path::PathBuf;
@@ -33,6 +31,8 @@ pub struct ShellRequest {
pub env: std::collections::HashMap<String, String>,
pub with_escalated_permissions: Option<bool>,
pub justification: Option<String>,
pub approval_requirement: ApprovalRequirement,
pub policy_allow_prefix: Option<Vec<String>>,
}
impl ProvidesSandboxRetryData for ShellRequest {
@@ -101,31 +101,39 @@ impl Approvable<ShellRequest> for ShellRuntime {
.clone()
.or_else(|| req.justification.clone());
let risk = ctx.risk.clone();
let policy_allow_prefix = req.policy_allow_prefix.clone();
let allow_prefix_for_request = policy_allow_prefix.clone();
let session = ctx.session;
let turn = ctx.turn;
let call_id = ctx.call_id.to_string();
Box::pin(async move {
with_cached_approval(&session.services, key, move || async move {
let decision = with_cached_approval(&session.services, key, move || async move {
session
.request_command_approval(turn, call_id, command, cwd, reason, risk)
.request_command_approval(
turn,
call_id,
command,
cwd,
reason,
risk,
allow_prefix_for_request.clone(),
)
.await
})
.await
.await;
if let Some(prefix) = policy_allow_prefix.as_ref()
&& matches!(decision, ReviewDecision::ApprovedAllowPrefix)
{
session.remember_allowed_prefix(prefix).await;
}
decision
})
}
fn wants_initial_approval(
&self,
req: &ShellRequest,
policy: AskForApproval,
sandbox_policy: &SandboxPolicy,
) -> bool {
requires_initial_appoval(
policy,
sandbox_policy,
&req.command,
req.with_escalated_permissions.unwrap_or(false),
)
fn approval_requirement(&self, req: &ShellRequest) -> Option<ApprovalRequirement> {
Some(req.approval_requirement.clone())
}
fn wants_escalated_first_attempt(&self, req: &ShellRequest) -> bool {

View File

@@ -1,4 +1,3 @@
use crate::command_safety::is_dangerous_command::requires_initial_appoval;
/*
Runtime: unified exec
@@ -10,6 +9,7 @@ use crate::error::SandboxErr;
use crate::tools::runtimes::build_command_spec;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ApprovalRequirement;
use crate::tools::sandboxing::ProvidesSandboxRetryData;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::SandboxRetryData;
@@ -22,9 +22,7 @@ use crate::tools::sandboxing::with_cached_approval;
use crate::unified_exec::UnifiedExecError;
use crate::unified_exec::UnifiedExecSession;
use crate::unified_exec::UnifiedExecSessionManager;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
use futures::future::BoxFuture;
use std::collections::HashMap;
use std::path::PathBuf;
@@ -36,6 +34,8 @@ pub struct UnifiedExecRequest {
pub env: HashMap<String, String>,
pub with_escalated_permissions: Option<bool>,
pub justification: Option<String>,
pub approval_requirement: ApprovalRequirement,
pub policy_allow_prefix: Option<Vec<String>>,
}
impl ProvidesSandboxRetryData for UnifiedExecRequest {
@@ -65,6 +65,8 @@ impl UnifiedExecRequest {
env: HashMap<String, String>,
with_escalated_permissions: Option<bool>,
justification: Option<String>,
approval_requirement: ApprovalRequirement,
policy_allow_prefix: Option<Vec<String>>,
) -> Self {
Self {
command,
@@ -72,6 +74,8 @@ impl UnifiedExecRequest {
env,
with_escalated_permissions,
justification,
approval_requirement,
policy_allow_prefix,
}
}
}
@@ -119,28 +123,36 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
.clone()
.or_else(|| req.justification.clone());
let risk = ctx.risk.clone();
let policy_allow_prefix = req.policy_allow_prefix.clone();
let allow_prefix_for_request = policy_allow_prefix.clone();
Box::pin(async move {
with_cached_approval(&session.services, key, || async move {
let decision = with_cached_approval(&session.services, key, || async move {
session
.request_command_approval(turn, call_id, command, cwd, reason, risk)
.request_command_approval(
turn,
call_id,
command,
cwd,
reason,
risk,
allow_prefix_for_request.clone(),
)
.await
})
.await
.await;
if let Some(prefix) = policy_allow_prefix.as_ref()
&& matches!(decision, ReviewDecision::ApprovedAllowPrefix)
{
session.remember_allowed_prefix(prefix).await;
}
decision
})
}
fn wants_initial_approval(
&self,
req: &UnifiedExecRequest,
policy: AskForApproval,
sandbox_policy: &SandboxPolicy,
) -> bool {
requires_initial_appoval(
policy,
sandbox_policy,
&req.command,
req.with_escalated_permissions.unwrap_or(false),
)
fn approval_requirement(&self, req: &UnifiedExecRequest) -> Option<ApprovalRequirement> {
Some(req.approval_requirement.clone())
}
fn wants_escalated_first_attempt(&self, req: &UnifiedExecRequest) -> bool {

View File

@@ -69,7 +69,10 @@ where
let decision = fetch().await;
if matches!(decision, ReviewDecision::ApprovedForSession) {
if matches!(
decision,
ReviewDecision::ApprovedForSession | ReviewDecision::ApprovedAllowPrefix
) {
let mut store = services.tool_approvals.lock().await;
store.put(key, ReviewDecision::ApprovedForSession);
}
@@ -86,6 +89,37 @@ pub(crate) struct ApprovalCtx<'a> {
pub risk: Option<SandboxCommandAssessment>,
}
// Specifies what tool orchestrator should do with a given tool call.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ApprovalRequirement {
/// No approval required for this tool call
Skip,
/// Approval required for this tool call
NeedsApproval { reason: Option<String> },
/// Execution forbidden for this tool call
Forbidden { reason: String },
}
/// - Never, OnFailure: do not ask
/// - OnRequest: ask unless sandbox policy is DangerFullAccess
/// - UnlessTrusted: always ask
pub(crate) fn default_approval_requirement(
policy: AskForApproval,
sandbox_policy: &SandboxPolicy,
) -> ApprovalRequirement {
let needs_approval = match policy {
AskForApproval::Never | AskForApproval::OnFailure => false,
AskForApproval::OnRequest => !matches!(sandbox_policy, SandboxPolicy::DangerFullAccess),
AskForApproval::UnlessTrusted => true,
};
if needs_approval {
ApprovalRequirement::NeedsApproval { reason: None }
} else {
ApprovalRequirement::Skip
}
}
pub(crate) trait Approvable<Req> {
type ApprovalKey: Hash + Eq + Clone + Debug + Serialize;
@@ -106,22 +140,11 @@ pub(crate) trait Approvable<Req> {
matches!(policy, AskForApproval::Never)
}
/// Decide whether an initial user approval should be requested before the
/// first attempt. Defaults to the orchestrator's behavior (prerefactor):
/// - Never, OnFailure: do not ask
/// - OnRequest: ask unless sandbox policy is DangerFullAccess
/// - UnlessTrusted: always ask
fn wants_initial_approval(
&self,
_req: &Req,
policy: AskForApproval,
sandbox_policy: &SandboxPolicy,
) -> bool {
match policy {
AskForApproval::Never | AskForApproval::OnFailure => false,
AskForApproval::OnRequest => !matches!(sandbox_policy, SandboxPolicy::DangerFullAccess),
AskForApproval::UnlessTrusted => true,
}
/// Override the default approval requirement. Return `Some(_)` to specify
/// a custom requirement, or `None` to fall back to
/// policy-based default.
fn approval_requirement(&self, _req: &Req) -> Option<ApprovalRequirement> {
None
}
/// Decide we can request an approval for no-sandbox execution.

View File

@@ -11,10 +11,12 @@ use crate::codex::TurnContext;
use crate::exec::ExecToolCallOutput;
use crate::exec::StreamOutput;
use crate::exec_env::create_env;
use crate::exec_policy::create_approval_requirement_for_command;
use crate::protocol::BackgroundEventEvent;
use crate::protocol::EventMsg;
use crate::protocol::ExecCommandSource;
use crate::sandboxing::ExecEnv;
use crate::sandboxing::SandboxPermissions;
use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx;
use crate::tools::events::ToolEventFailure;
@@ -443,12 +445,29 @@ impl UnifiedExecSessionManager {
) -> Result<UnifiedExecSession, UnifiedExecError> {
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = UnifiedExecRuntime::new(self);
let additional_allow_prefixes = context
.session
.services
.exec_policy_overrides
.lock()
.await
.clone();
let approval = create_approval_requirement_for_command(
&context.turn.exec_policy,
&additional_allow_prefixes,
command,
context.turn.approval_policy,
&context.turn.sandbox_policy,
SandboxPermissions::from(with_escalated_permissions.unwrap_or(false)),
);
let req = UnifiedExecToolRequest::new(
command.to_vec(),
cwd,
create_env(&context.turn.shell_environment_policy),
with_escalated_permissions,
justification,
approval.requirement,
approval.allow_prefix,
);
let tool_ctx = ToolCtx {
session: context.session.as_ref(),

View File

@@ -0,0 +1,101 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
use anyhow::Result;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
use codex_protocol::config_types::ReasoningSummary;
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::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use serde_json::json;
use std::fs;
#[tokio::test]
async fn execpolicy_blocks_shell_invocation() -> Result<()> {
let mut builder = test_codex().with_config(|config| {
let policy_path = config.codex_home.join("policy").join("policy.codexpolicy");
fs::create_dir_all(
policy_path
.parent()
.expect("policy directory must have a parent"),
)
.expect("create policy directory");
fs::write(
&policy_path,
r#"prefix_rule(pattern=["echo"], decision="forbidden")"#,
)
.expect("write policy file");
});
let server = start_mock_server().await;
let test = builder.build(&server).await?;
let call_id = "shell-forbidden";
let args = json!({
"command": ["echo", "blocked"],
"timeout_ms": 1_000,
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
)
.await;
let session_model = test.session_configured.model.clone();
test.codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "run shell command".into(),
}],
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
})
.await?;
let EventMsg::ExecCommandEnd(end) = wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::ExecCommandEnd(_))
})
.await
else {
unreachable!()
};
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TaskComplete(_))
})
.await;
assert!(
end.aggregated_output
.contains("execpolicy forbids this command"),
"unexpected output: {}",
end.aggregated_output
);
Ok(())
}

View File

@@ -28,6 +28,7 @@ mod compact_remote;
mod compact_resume_fork;
mod deprecation_notice;
mod exec;
mod exec_policy;
mod fork_conversation;
mod grep_files;
mod items;

View File

@@ -27,3 +27,4 @@ thiserror = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }

View File

@@ -5,7 +5,7 @@
- This release covers only the prefix-rule subset of the planned execpolicy v2 language; a richer language will follow.
- Tokens are matched in order; any `pattern` element may be a list to denote alternatives. `decision` defaults to `allow`; valid values: `allow`, `prompt`, `forbidden`.
- `match` / `not_match` supply example invocations that are validated at load time (think of them as unit tests); examples can be token arrays or strings (strings are tokenized with `shlex`).
- The CLI always prints the JSON serialization of the evaluation result (whether a match or not).
- The CLI always prints the JSON serialization of the evaluation result.
## Policy shapes
- Prefix rules use Starlark syntax:
@@ -18,6 +18,20 @@ prefix_rule(
)
```
## CLI
- Provide one or more policy files (for example `src/default.codexpolicy`) to check a command:
```bash
cargo run -p codex-execpolicy2 -- check --policy path/to/policy.codexpolicy git status
```
- Pass multiple `--policy` flags to merge rules, evaluated in the order provided:
```bash
cargo run -p codex-execpolicy2 -- check --policy base.codexpolicy --policy overrides.codexpolicy git status
```
- Output is JSON by default; pass `--pretty` for pretty-printed JSON
- Example outcomes:
- Match: `{"match": { ... "decision": "allow" ... }}`
- No match: `"noMatch"`
## Response shapes
- Match:
```json
@@ -43,17 +57,3 @@ prefix_rule(
- `matchedRules` lists every rule whose prefix matched the command; `matchedPrefix` is the exact prefix that matched.
- The effective `decision` is the strictest severity across all matches (`forbidden` > `prompt` > `allow`).
## CLI
- Provide one or more policy files (for example `src/default.codexpolicy`) to check a command:
```bash
cargo run -p codex-execpolicy2 -- check --policy path/to/policy.codexpolicy git status
```
- Pass multiple `--policy` flags to merge rules, evaluated in the order provided:
```bash
cargo run -p codex-execpolicy2 -- check --policy base.codexpolicy --policy overrides.codexpolicy git status
```
- Output is newline-delimited JSON by default; pass `--pretty` for pretty-printed JSON if desired.
- Example outcomes:
- Match: `{"match": { ... "decision": "allow" ... }}`
- No match: `"noMatch"`

View File

@@ -24,4 +24,12 @@ impl Decision {
other => Err(Error::InvalidDecision(other.to_string())),
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Allow => "allow",
Self::Prompt => "prompt",
Self::Forbidden => "forbidden",
}
}
}

View File

@@ -3,6 +3,7 @@ pub mod error;
pub mod parser;
pub mod policy;
pub mod rule;
pub mod writer;
pub use decision::Decision;
pub use error::Error;
@@ -13,3 +14,5 @@ pub use policy::Policy;
pub use rule::Rule;
pub use rule::RuleMatch;
pub use rule::RuleRef;
pub use writer::WritePolicyError;
pub use writer::append_prefix_rule;

View File

@@ -15,6 +15,10 @@ impl Policy {
Self { rules_by_program }
}
pub fn empty() -> Self {
Self::new(MultiMap::new())
}
pub fn rules(&self) -> &MultiMap<String, RuleRef> {
&self.rules_by_program
}

View File

@@ -0,0 +1,81 @@
use std::fs;
use std::io::Write;
use std::path::Path;
use thiserror::Error;
use crate::decision::Decision;
#[derive(Debug, Error)]
pub enum WritePolicyError {
#[error("failed to create policy directory {dir}: {source}")]
CreateDir { dir: String, source: std::io::Error },
#[error("failed to read policy file {path}: {source}")]
ReadFile {
path: String,
source: std::io::Error,
},
#[error("failed to write policy file {path}: {source}")]
WriteFile {
path: String,
source: std::io::Error,
},
}
/// Append a prefix rule in Starlark form to the given policy file, creating any missing
/// parent directories or the file itself. Currently only supports writing a single
/// `prefix_rule` with a fixed decision.
pub fn append_prefix_rule(
policy_path: &Path,
pattern: &[String],
decision: Decision,
) -> Result<(), WritePolicyError> {
let parent = policy_path
.parent()
.ok_or_else(|| WritePolicyError::CreateDir {
dir: policy_path.display().to_string(),
source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing parent"),
})?;
if let Err(source) = fs::create_dir_all(parent) {
return Err(WritePolicyError::CreateDir {
dir: parent.display().to_string(),
source,
});
}
let mut buf = Vec::new();
if let Ok(existing) = fs::read_to_string(policy_path) {
buf.push(existing);
if !buf.last().is_some_and(|s| s.ends_with('\n')) {
buf.push("\n".to_string());
}
}
let serialized_pattern = serialize_pattern(pattern);
let decision_str = decision.as_str();
let line = format!("prefix_rule(pattern={serialized_pattern}, decision=\"{decision_str}\")\n");
buf.push(line);
let contents = buf.concat();
fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(policy_path)
.and_then(|mut f| f.write_all(contents.as_bytes()))
.map_err(|source| WritePolicyError::WriteFile {
path: policy_path.display().to_string(),
source,
})
}
fn serialize_pattern(pattern: &[String]) -> String {
let tokens: Vec<String> = pattern
.iter()
.map(|token| serde_json::to_string(token).unwrap_or_else(|_| "\"\"".to_string()))
.collect();
format!("[{}]", tokens.join(", "))
}

View File

@@ -0,0 +1,40 @@
use codex_execpolicy2::Decision;
use codex_execpolicy2::append_prefix_rule;
use std::fs;
use tempfile::tempdir;
#[test]
fn appends_creating_missing_policy_file() {
let dir = tempdir().expect("create temp dir");
let path = dir.path().join("policy").join("default.codexpolicy");
append_prefix_rule(
&path,
&["rg".to_string(), "--files".to_string()],
Decision::Allow,
)
.expect("append rule");
let contents = fs::read_to_string(path).expect("read policy");
assert_eq!(
contents,
"prefix_rule(pattern=[\"rg\", \"--files\"], decision=\"allow\")\n"
);
}
#[test]
fn appends_rules_with_newlines() {
let dir = tempdir().expect("create temp dir");
let path = dir.path().join("policy.codexpolicy");
append_prefix_rule(&path, &["ls".to_string()], Decision::Allow).expect("append first rule");
append_prefix_rule(&path, &["pwd".to_string()], Decision::Allow).expect("append second rule");
let contents = fs::read_to_string(path).expect("read policy");
assert_eq!(
contents,
"\
prefix_rule(pattern=[\"ls\"], decision=\"allow\")
prefix_rule(pattern=[\"pwd\"], decision=\"allow\")
"
);
}

View File

@@ -181,6 +181,7 @@ async fn run_codex_tool_session_inner(
reason: _,
risk,
parsed_cmd,
allow_prefix: _,
}) => {
handle_exec_approval_request(
command,

View File

@@ -51,6 +51,9 @@ pub struct ExecApprovalRequestEvent {
#[serde(skip_serializing_if = "Option::is_none")]
pub risk: Option<SandboxCommandAssessment>,
pub parsed_cmd: Vec<ParsedCommand>,
/// Optional command prefix that can be allowlisted to avoid future prompts.
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_prefix: Option<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]

View File

@@ -1533,6 +1533,11 @@ pub enum ReviewDecision {
/// remainder of the session.
ApprovedForSession,
/// User has approved this command and wants Codex to allow future commands
/// sharing the same prefix without prompting again. Codex will persist an
/// allow rule in the default execpolicy file.
ApprovedAllowPrefix,
/// User has denied this command and the agent should not execute it, but
/// it should continue the session and try something else.
#[default]

View File

@@ -41,6 +41,7 @@ pub(crate) enum ApprovalRequest {
command: Vec<String>,
reason: Option<String>,
risk: Option<SandboxCommandAssessment>,
allow_prefix: Option<Vec<String>>,
},
ApplyPatch {
id: String,
@@ -97,8 +98,8 @@ impl ApprovalOverlay {
header: Box<dyn Renderable>,
) -> (Vec<ApprovalOption>, SelectionViewParams) {
let (options, title) = match &variant {
ApprovalVariant::Exec { .. } => (
exec_options(),
ApprovalVariant::Exec { allow_prefix, .. } => (
exec_options(allow_prefix.is_some()),
"Would you like to run the following command?".to_string(),
),
ApprovalVariant::ApplyPatch { .. } => (
@@ -150,7 +151,7 @@ impl ApprovalOverlay {
};
if let Some(variant) = self.current_variant.as_ref() {
match (&variant, option.decision) {
(ApprovalVariant::Exec { id, command }, decision) => {
(ApprovalVariant::Exec { id, command, .. }, decision) => {
self.handle_exec_decision(id, command, decision);
}
(ApprovalVariant::ApplyPatch { id, .. }, decision) => {
@@ -238,7 +239,7 @@ impl BottomPaneView for ApprovalOverlay {
&& let Some(variant) = self.current_variant.as_ref()
{
match &variant {
ApprovalVariant::Exec { id, command } => {
ApprovalVariant::Exec { id, command, .. } => {
self.handle_exec_decision(id, command, ReviewDecision::Abort);
}
ApprovalVariant::ApplyPatch { id, .. } => {
@@ -291,6 +292,7 @@ impl From<ApprovalRequest> for ApprovalRequestState {
command,
reason,
risk,
allow_prefix,
} => {
let reason = reason.filter(|item| !item.is_empty());
let has_reason = reason.is_some();
@@ -310,7 +312,11 @@ impl From<ApprovalRequest> for ApprovalRequestState {
}
header.extend(full_cmd_lines);
Self {
variant: ApprovalVariant::Exec { id, command },
variant: ApprovalVariant::Exec {
id,
command,
allow_prefix,
},
header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })),
}
}
@@ -364,8 +370,14 @@ fn render_risk_lines(risk: &SandboxCommandAssessment) -> Vec<Line<'static>> {
#[derive(Clone)]
enum ApprovalVariant {
Exec { id: String, command: Vec<String> },
ApplyPatch { id: String },
Exec {
id: String,
command: Vec<String>,
allow_prefix: Option<Vec<String>>,
},
ApplyPatch {
id: String,
},
}
#[derive(Clone)]
@@ -384,8 +396,8 @@ impl ApprovalOption {
}
}
fn exec_options() -> Vec<ApprovalOption> {
vec![
fn exec_options(include_allow_prefix: bool) -> Vec<ApprovalOption> {
let mut options = vec![
ApprovalOption {
label: "Yes, proceed".to_string(),
decision: ReviewDecision::Approved,
@@ -398,13 +410,25 @@ fn exec_options() -> Vec<ApprovalOption> {
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
},
ApprovalOption {
label: "No, and tell Codex what to do differently".to_string(),
decision: ReviewDecision::Abort,
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
},
]
];
if include_allow_prefix {
options.push(ApprovalOption {
label: "Yes, and don't ask again for commands with this prefix".to_string(),
decision: ReviewDecision::ApprovedAllowPrefix,
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))],
});
}
options.push(ApprovalOption {
label: "No, and tell Codex what to do differently".to_string(),
decision: ReviewDecision::Abort,
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
});
options
}
fn patch_options() -> Vec<ApprovalOption> {
@@ -428,6 +452,7 @@ fn patch_options() -> Vec<ApprovalOption> {
mod tests {
use super::*;
use crate::app_event::AppEvent;
use codex_core::protocol::Op;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::unbounded_channel;
@@ -437,6 +462,7 @@ mod tests {
command: vec!["echo".to_string(), "hi".to_string()],
reason: Some("reason".to_string()),
risk: None,
allow_prefix: None,
}
}
@@ -469,6 +495,27 @@ mod tests {
assert!(saw_op, "expected approval decision to emit an op");
}
#[test]
fn prefix_option_emits_allow_prefix_decision() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut request = make_exec_request();
if let ApprovalRequest::Exec { allow_prefix, .. } = &mut request {
*allow_prefix = Some(vec!["rg".into()]);
}
let mut view = ApprovalOverlay::new(request, tx);
view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
let mut decision = None;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::CodexOp(Op::ExecApproval { decision: dec, .. }) = ev {
decision = Some(dec);
break;
}
}
assert_eq!(decision, Some(ReviewDecision::ApprovedAllowPrefix));
}
#[test]
fn header_includes_command_snippet() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
@@ -479,6 +526,7 @@ mod tests {
command,
reason: None,
risk: None,
allow_prefix: None,
};
let view = ApprovalOverlay::new(exec_request, tx);

View File

@@ -540,6 +540,7 @@ mod tests {
command: vec!["echo".into(), "ok".into()],
reason: None,
risk: None,
allow_prefix: None,
}
}

View File

@@ -1012,6 +1012,7 @@ impl ChatWidget {
command: ev.command,
reason: ev.reason,
risk: ev.risk,
allow_prefix: ev.allow_prefix,
};
self.bottom_pane.push_approval_request(request);
self.request_redraw();

View File

@@ -4,6 +4,9 @@ use codex_core::CodexConversation;
use codex_core::ConversationManager;
use codex_core::NewConversation;
use codex_core::config::Config;
use codex_core::protocol::ErrorEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::mpsc::unbounded_channel;
@@ -28,9 +31,19 @@ pub(crate) fn spawn_agent(
session_configured,
} = match server.new_conversation(config).await {
Ok(v) => v,
Err(e) => {
// TODO: surface this error to the user.
tracing::error!("failed to initialize codex: {e}");
#[allow(clippy::print_stderr)]
Err(err) => {
let message = err.to_string();
eprintln!("{message}");
app_event_tx_clone.send(AppEvent::CodexEvent(Event {
id: "".to_string(),
msg: EventMsg::Error(ErrorEvent {
message,
http_status_code: None,
}),
}));
app_event_tx_clone.send(AppEvent::ExitRequest);
tracing::error!("failed to initialize codex: {err}");
return;
}
};

View File

@@ -588,6 +588,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() {
),
risk: None,
parsed_cmd: vec![],
allow_prefix: None,
};
chat.handle_codex_event(Event {
id: "sub-short".into(),
@@ -632,6 +633,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
),
risk: None,
parsed_cmd: vec![],
allow_prefix: None,
};
chat.handle_codex_event(Event {
id: "sub-multi".into(),
@@ -682,6 +684,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
reason: None,
risk: None,
parsed_cmd: vec![],
allow_prefix: None,
};
chat.handle_codex_event(Event {
id: "sub-long".into(),
@@ -1831,6 +1834,7 @@ fn approval_modal_exec_snapshot() {
),
risk: None,
parsed_cmd: vec![],
allow_prefix: None,
};
chat.handle_codex_event(Event {
id: "sub-approve".into(),
@@ -1877,6 +1881,7 @@ fn approval_modal_exec_without_reason_snapshot() {
reason: None,
risk: None,
parsed_cmd: vec![],
allow_prefix: None,
};
chat.handle_codex_event(Event {
id: "sub-approve-noreason".into(),
@@ -2089,6 +2094,7 @@ fn status_widget_and_approval_modal_snapshot() {
),
risk: None,
parsed_cmd: vec![],
allow_prefix: None,
};
chat.handle_codex_event(Event {
id: "sub-approve-exec".into(),

View File

@@ -421,6 +421,19 @@ pub fn new_approval_decision_cell(
],
)
}
ApprovedAllowPrefix => {
let snippet = Span::from(exec_snippet(&command)).dim();
(
"".green(),
vec![
"You ".into(),
"approved".bold(),
" codex to run ".into(),
snippet,
" without prompting for this prefix".bold(),
],
)
}
Denied => {
let snippet = Span::from(exec_snippet(&command)).dim();
(