Compare commits

...

5 Commits

Author SHA1 Message Date
Fouad Matin
09ce495f15 fix: fmt 2025-10-18 16:41:25 -07:00
Fouad Matin
e68b4617e3 fix(command_safety): git checkout should ask user 2025-10-15 13:16:08 -07:00
Michael Bolin
f38ad65254 chore: standardize on ParsedCommand from codex_protocol (#5218)
Note these two types were identical, so it seems clear to standardize on the one in `codex_protocol` and eliminate the `Into` stuff.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/5218).
* #5222
* __->__ #5218
2025-10-15 13:00:22 -07:00
jif-oai
774892c6d7 feat: add auto-approval for codex exec (#5043) 2025-10-15 19:03:54 +01:00
jif-oai
897d4d5f17 feat: agent override file (#5215)
Add a file that overrides `AGENTS.md` but is not versioned (for local
devs)
2025-10-15 17:46:01 +01:00
11 changed files with 204 additions and 75 deletions

1
.gitignore vendored
View File

@@ -30,6 +30,7 @@ result
# cli tools
CLAUDE.md
.claude/
AGENTS.override.md
# caches
.cache/

View File

@@ -882,10 +882,7 @@ impl Session {
call_id,
command: command_for_display.clone(),
cwd,
parsed_cmd: parse_command(&command_for_display)
.into_iter()
.map(Into::into)
.collect(),
parsed_cmd: parse_command(&command_for_display),
}),
};
let event = Event {

View File

@@ -22,7 +22,10 @@ fn is_dangerous_to_call_with_exec(command: &[String]) -> bool {
match cmd0 {
Some(cmd) if cmd.ends_with("git") || cmd.ends_with("/git") => {
matches!(command.get(1).map(String::as_str), Some("reset" | "rm"))
matches!(
command.get(1).map(String::as_str),
Some("reset" | "rm" | "checkout")
)
}
Some("rm") => matches!(command.get(1).map(String::as_str), Some("-f" | "-rf")),

View File

@@ -28,6 +28,8 @@ use crate::model_family::find_family_for_model;
use crate::model_provider_info::ModelProviderInfo;
use crate::model_provider_info::built_in_model_providers;
use crate::openai_model_info::get_model_info;
use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME;
use crate::protocol::AskForApproval;
use crate::protocol::SandboxPolicy;
use anyhow::Context;
@@ -1123,6 +1125,15 @@ impl Config {
.or(cfg.review_model)
.unwrap_or_else(default_review_model);
let mut approval_policy = approval_policy
.or(config_profile.approval_policy)
.or(cfg.approval_policy)
.unwrap_or_else(AskForApproval::default);
if features.enabled(Feature::ApproveAll) {
approval_policy = AskForApproval::OnRequest;
}
let config = Self {
model,
review_model,
@@ -1133,10 +1144,7 @@ impl Config {
model_provider_id,
model_provider,
cwd: resolved_cwd,
approval_policy: approval_policy
.or(config_profile.approval_policy)
.or(cfg.approval_policy)
.unwrap_or_else(AskForApproval::default),
approval_policy,
sandbox_policy,
shell_environment_policy,
notify: cfg.notify,
@@ -1217,20 +1225,18 @@ impl Config {
}
fn load_instructions(codex_dir: Option<&Path>) -> Option<String> {
let mut p = match codex_dir {
Some(p) => p.to_path_buf(),
None => return None,
};
p.push("AGENTS.md");
std::fs::read_to_string(&p).ok().and_then(|s| {
let s = s.trim();
if s.is_empty() {
None
} else {
Some(s.to_string())
let base = codex_dir?;
for candidate in [LOCAL_PROJECT_DOC_FILENAME, DEFAULT_PROJECT_DOC_FILENAME] {
let mut path = base.to_path_buf();
path.push(candidate);
if let Ok(contents) = std::fs::read_to_string(&path) {
let trimmed = contents.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
})
}
None
}
fn get_base_instructions(
@@ -1432,6 +1438,26 @@ exclude_slash_tmp = true
);
}
#[test]
fn approve_all_feature_forces_on_request_policy() -> std::io::Result<()> {
let cfg = r#"
[features]
approve_all = true
"#;
let parsed = toml::from_str::<ConfigToml>(cfg)
.expect("TOML deserialization should succeed for approve_all feature");
let temp_dir = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
parsed,
ConfigOverrides::default(),
temp_dir.path().to_path_buf(),
)?;
assert!(config.features.enabled(Feature::ApproveAll));
assert_eq!(config.approval_policy, AskForApproval::OnRequest);
Ok(())
}
#[test]
fn config_defaults_to_auto_oauth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;

View File

@@ -41,6 +41,8 @@ pub enum Feature {
ViewImageTool,
/// Allow the model to request web searches.
WebSearchRequest,
/// Automatically approve all approval requests from the harness.
ApproveAll,
}
impl Feature {
@@ -247,4 +249,10 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: false,
},
FeatureSpec {
id: Feature::ApproveAll,
key: "approve_all",
stage: Stage::Experimental,
default_enabled: false,
},
];

View File

@@ -1,44 +1,9 @@
use crate::bash::try_parse_bash;
use crate::bash::try_parse_word_only_commands_sequence;
use serde::Deserialize;
use serde::Serialize;
use codex_protocol::parse_command::ParsedCommand;
use shlex::split as shlex_split;
use shlex::try_join as shlex_try_join;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub enum ParsedCommand {
Read {
cmd: String,
name: String,
},
ListFiles {
cmd: String,
path: Option<String>,
},
Search {
cmd: String,
query: Option<String>,
path: Option<String>,
},
Unknown {
cmd: String,
},
}
// Convert core's parsed command enum into the protocol's simplified type so
// events can carry the canonical representation across process boundaries.
impl From<ParsedCommand> for codex_protocol::parse_command::ParsedCommand {
fn from(v: ParsedCommand) -> Self {
use codex_protocol::parse_command::ParsedCommand as P;
match v {
ParsedCommand::Read { cmd, name } => P::Read { cmd, name },
ParsedCommand::ListFiles { cmd, path } => P::ListFiles { cmd, path },
ParsedCommand::Search { cmd, query, path } => P::Search { cmd, query, path },
ParsedCommand::Unknown { cmd } => P::Unknown { cmd },
}
}
}
fn shlex_join(tokens: &[String]) -> String {
shlex_try_join(tokens.iter().map(String::as_str))
.unwrap_or_else(|_| "<command included NUL byte>".to_string())

View File

@@ -21,6 +21,8 @@ use tracing::error;
/// Default filename scanned for project-level docs.
pub const DEFAULT_PROJECT_DOC_FILENAME: &str = "AGENTS.md";
/// Preferred local override for project-level docs.
pub const LOCAL_PROJECT_DOC_FILENAME: &str = "AGENTS.override.md";
/// When both `Config::instructions` and the project doc are present, they will
/// be concatenated with the following separator.
@@ -178,7 +180,8 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBu
fn candidate_filenames<'a>(config: &'a Config) -> Vec<&'a str> {
let mut names: Vec<&'a str> =
Vec::with_capacity(1 + config.project_doc_fallback_filenames.len());
Vec::with_capacity(2 + config.project_doc_fallback_filenames.len());
names.push(LOCAL_PROJECT_DOC_FILENAME);
names.push(DEFAULT_PROJECT_DOC_FILENAME);
for candidate in &config.project_doc_fallback_filenames {
let candidate = candidate.as_str();
@@ -381,6 +384,29 @@ mod tests {
assert_eq!(res, "root doc\n\ncrate doc");
}
/// AGENTS.override.md is preferred over AGENTS.md when both are present.
#[tokio::test]
async fn agents_local_md_preferred() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join(DEFAULT_PROJECT_DOC_FILENAME), "versioned").unwrap();
fs::write(tmp.path().join(LOCAL_PROJECT_DOC_FILENAME), "local").unwrap();
let cfg = make_config(&tmp, 4096, None);
let res = get_user_instructions(&cfg)
.await
.expect("local doc expected");
assert_eq!(res, "local");
let discovery = discover_project_doc_paths(&cfg).expect("discover paths");
assert_eq!(discovery.len(), 1);
assert_eq!(
discovery[0].file_name().unwrap().to_string_lossy(),
LOCAL_PROJECT_DOC_FILENAME
);
}
/// When AGENTS.md is absent but a configured fallback exists, the fallback is used.
#[tokio::test]
async fn uses_configured_fallback_when_agents_missing() {

View File

@@ -17,6 +17,7 @@ use codex_core::ConversationManager;
use codex_core::NewConversation;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::features::Feature;
use codex_core::git_info::get_git_repo_root;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::Event;
@@ -168,8 +169,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
model,
review_model: None,
config_profile,
// This CLI is intended to be headless and has no affordances for asking
// the user for approval.
// Default to never ask for approvals in headless mode. Feature flags can override.
approval_policy: Some(AskForApproval::Never),
sandbox_mode,
cwd: cwd.map(|p| p.canonicalize().unwrap_or(p)),
@@ -192,6 +192,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
};
let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides).await?;
let approve_all_enabled = config.features.enabled(Feature::ApproveAll);
let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"));
@@ -360,6 +361,34 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
if matches!(event.msg, EventMsg::Error(_)) {
error_seen = true;
}
// Auto-approve requests when the approve_all feature is enabled.
if approve_all_enabled {
match &event.msg {
EventMsg::ExecApprovalRequest(_) => {
if let Err(e) = conversation
.submit(Op::ExecApproval {
id: event.id.clone(),
decision: codex_core::protocol::ReviewDecision::Approved,
})
.await
{
error!("failed to auto-approve exec: {e}");
}
}
EventMsg::ApplyPatchApprovalRequest(_) => {
if let Err(e) = conversation
.submit(Op::PatchApproval {
id: event.id.clone(),
decision: codex_core::protocol::ReviewDecision::Approved,
})
.await
{
error!("failed to auto-approve patch: {e}");
}
}
_ => {}
}
}
let shutdown: CodexStatus = event_processor.process_event(event);
match shutdown {
CodexStatus::Running => continue,

View File

@@ -0,0 +1,81 @@
#![cfg(not(target_os = "windows"))]
#![allow(clippy::expect_used, clippy::unwrap_used)]
use anyhow::Result;
use core_test_support::responses;
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_sequence;
use core_test_support::responses::sse;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex_exec::test_codex_exec;
use serde_json::Value;
use serde_json::json;
async fn run_exec_with_args(args: &[&str]) -> Result<String> {
let test = test_codex_exec();
let call_id = "exec-approve";
let exec_args = json!({
"command": [
if cfg!(windows) { "cmd.exe" } else { "/bin/sh" },
if cfg!(windows) { "/C" } else { "-lc" },
"echo approve-all-ok",
],
"timeout_ms": 1500,
"with_escalated_permissions": true
});
let response_streams = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell", &serde_json::to_string(&exec_args)?),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
];
let server = responses::start_mock_server().await;
let mock = mount_sse_sequence(&server, response_streams).await;
test.cmd_with_server(&server).args(args).assert().success();
let requests = mock.requests();
assert!(requests.len() >= 2, "expected at least two responses POSTs");
let item = requests[1].function_call_output(call_id);
let output_str = item
.get("output")
.and_then(Value::as_str)
.expect("function_call_output.output should be a string");
Ok(output_str.to_string())
}
/// Setting `features.approve_all=true` should switch to auto-approvals.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn approve_all_auto_accepts_exec() -> Result<()> {
skip_if_no_network!(Ok(()));
let output = run_exec_with_args(&[
"--skip-git-repo-check",
"-c",
"features.approve_all=true",
"train",
])
.await?;
assert!(
output.contains("Exit code: 0"),
"expected Exit code: 0 in output: {output}"
);
assert!(
output.contains("approve-all-ok"),
"expected command output in response: {output}"
);
Ok(())
}

View File

@@ -1,5 +1,6 @@
// Aggregates all former standalone integration tests as modules.
mod apply_patch;
mod approve_all;
mod auth_env;
mod originator;
mod output_schema;

View File

@@ -505,10 +505,7 @@ fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) {
// Build the full command vec and parse it using core's parser,
// then convert to protocol variants for the event payload.
let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()];
let parsed_cmd: Vec<ParsedCommand> = codex_core::parse_command::parse_command(&command)
.into_iter()
.map(Into::into)
.collect();
let parsed_cmd: Vec<ParsedCommand> = codex_core::parse_command::parse_command(&command);
chat.handle_codex_event(Event {
id: call_id.to_string(),
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
@@ -1205,10 +1202,7 @@ async fn binary_size_transcript_snapshot() {
call_id: e.call_id.clone(),
command: e.command,
cwd: e.cwd,
parsed_cmd: parsed_cmd
.into_iter()
.map(std::convert::Into::into)
.collect(),
parsed_cmd,
}),
}
}
@@ -2241,17 +2235,15 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() {
command: vec!["bash".into(), "-lc".into(), "rg \"Change Approved\"".into()],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
parsed_cmd: vec![
codex_core::parse_command::ParsedCommand::Search {
ParsedCommand::Search {
query: Some("Change Approved".into()),
path: None,
cmd: "rg \"Change Approved\"".into(),
}
.into(),
codex_core::parse_command::ParsedCommand::Read {
},
ParsedCommand::Read {
name: "diff_render.rs".into(),
cmd: "cat diff_render.rs".into(),
}
.into(),
},
],
}),
});