fix: keep shell escalation exec paths absolute (#12750)
## Why In the `shell_zsh_fork` flow, `codex-shell-escalation` receives the executable path exactly as the shell passed it to `execve()`. That path is not guaranteed to be absolute. For commands such as `./scripts/hello-mbolin.sh`, if the shell was launched with a different `workdir`, resolving the intercepted `file` against the server process working directory makes policy checks and skill matching inspect the wrong executable. This change pushes that fix a step further by keeping the normalized path typed as `AbsolutePathBuf` throughout the rest of the escalation pipeline. That makes the absolute-path invariant explicit, so later code cannot accidentally treat the resolved executable path as an arbitrary `PathBuf`. ## What Changed - record the wrapper process working directory as an `AbsolutePathBuf` - update the escalation protocol so `workdir` is explicitly absolute while `file` remains the raw intercepted exec path - resolve a relative intercepted `file` against the request `workdir` as soon as the server receives the request - thread `AbsolutePathBuf` through `EscalationPolicy`, `CoreShellActionProvider`, and command normalization helpers so the resolved executable path stays type-checked as absolute - replace the `path-absolutize` dependency in `codex-shell-escalation` with `codex-utils-absolute-path` - add a regression test that covers a relative `file` with a distinct `workdir` ## Verification - `cargo test -p codex-shell-escalation`
This commit is contained in:
parent
59398125f6
commit
c4ec6be4ab
7 changed files with 97 additions and 33 deletions
2
codex-rs/Cargo.lock
generated
2
codex-rs/Cargo.lock
generated
|
|
@ -2241,8 +2241,8 @@ dependencies = [
|
|||
"anyhow",
|
||||
"async-trait",
|
||||
"clap",
|
||||
"codex-utils-absolute-path",
|
||||
"libc",
|
||||
"path-absolutize",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ use codex_shell_escalation::ExecParams;
|
|||
use codex_shell_escalation::ExecResult;
|
||||
use codex_shell_escalation::ShellCommandExecutor;
|
||||
use codex_shell_escalation::Stopwatch;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
|
@ -175,9 +175,9 @@ impl CoreShellActionProvider {
|
|||
|
||||
async fn prompt(
|
||||
&self,
|
||||
program: &Path,
|
||||
program: &AbsolutePathBuf,
|
||||
argv: &[String],
|
||||
workdir: &Path,
|
||||
workdir: &AbsolutePathBuf,
|
||||
stopwatch: &Stopwatch,
|
||||
) -> anyhow::Result<ReviewDecision> {
|
||||
let command = join_program_and_argv(program, argv);
|
||||
|
|
@ -208,7 +208,7 @@ impl CoreShellActionProvider {
|
|||
/// Because we should be intercepting execve(2) calls, `program` should be
|
||||
/// an absolute path. The idea is that we check to see whether it matches
|
||||
/// any skills.
|
||||
async fn find_skill(&self, program: &Path) -> Option<SkillMetadata> {
|
||||
async fn find_skill(&self, program: &AbsolutePathBuf) -> Option<SkillMetadata> {
|
||||
let force_reload = false;
|
||||
let skills_outcome = self
|
||||
.session
|
||||
|
|
@ -217,12 +217,13 @@ impl CoreShellActionProvider {
|
|||
.skills_for_cwd(&self.turn.cwd, force_reload)
|
||||
.await;
|
||||
|
||||
let program_path = program.as_path();
|
||||
for skill in skills_outcome.skills {
|
||||
// We intentionally ignore "enabled" status here for now.
|
||||
let Some(skill_root) = skill.path_to_skills_md.parent() else {
|
||||
continue;
|
||||
};
|
||||
if program.starts_with(skill_root.join("scripts")) {
|
||||
if program_path.starts_with(skill_root.join("scripts")) {
|
||||
return Some(skill);
|
||||
}
|
||||
}
|
||||
|
|
@ -234,9 +235,9 @@ impl CoreShellActionProvider {
|
|||
&self,
|
||||
decision: Decision,
|
||||
needs_escalation: bool,
|
||||
program: &Path,
|
||||
program: &AbsolutePathBuf,
|
||||
argv: &[String],
|
||||
workdir: &Path,
|
||||
workdir: &AbsolutePathBuf,
|
||||
) -> anyhow::Result<EscalateAction> {
|
||||
let action = match decision {
|
||||
Decision::Forbidden => EscalateAction::Deny {
|
||||
|
|
@ -304,9 +305,9 @@ impl CoreShellActionProvider {
|
|||
impl EscalationPolicy for CoreShellActionProvider {
|
||||
async fn determine_action(
|
||||
&self,
|
||||
program: &Path,
|
||||
program: &AbsolutePathBuf,
|
||||
argv: &[String],
|
||||
workdir: &Path,
|
||||
workdir: &AbsolutePathBuf,
|
||||
) -> anyhow::Result<EscalateAction> {
|
||||
tracing::debug!(
|
||||
"Determining escalation action for command {program:?} with args {argv:?} in {workdir:?}"
|
||||
|
|
@ -483,7 +484,7 @@ fn map_exec_result(
|
|||
/// The intercepted `argv` includes `argv[0]`, but once we have normalized the
|
||||
/// executable path in `program`, we should replace the original `argv[0]`
|
||||
/// rather than duplicating it as an apparent user argument.
|
||||
fn join_program_and_argv(program: &Path, argv: &[String]) -> Vec<String> {
|
||||
fn join_program_and_argv(program: &AbsolutePathBuf, argv: &[String]) -> Vec<String> {
|
||||
std::iter::once(program.to_string_lossy().to_string())
|
||||
.chain(argv.iter().skip(1).cloned())
|
||||
.collect::<Vec<_>>()
|
||||
|
|
@ -497,8 +498,8 @@ mod tests {
|
|||
use super::map_exec_result;
|
||||
use crate::exec::SandboxType;
|
||||
use codex_shell_escalation::ExecResult;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
|
|
@ -523,13 +524,16 @@ mod tests {
|
|||
fn join_program_and_argv_replaces_original_argv_zero() {
|
||||
assert_eq!(
|
||||
join_program_and_argv(
|
||||
Path::new("/tmp/tool"),
|
||||
&AbsolutePathBuf::from_absolute_path("/tmp/tool").unwrap(),
|
||||
&["./tool".into(), "--flag".into(), "value".into()],
|
||||
),
|
||||
vec!["/tmp/tool", "--flag", "value"]
|
||||
);
|
||||
assert_eq!(
|
||||
join_program_and_argv(Path::new("/tmp/tool"), &["./tool".into()]),
|
||||
join_program_and_argv(
|
||||
&AbsolutePathBuf::from_absolute_path("/tmp/tool").unwrap(),
|
||||
&["./tool".into()]
|
||||
),
|
||||
vec!["/tmp/tool"]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ path = "src/bin/main_execve_wrapper.rs"
|
|||
anyhow = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
path-absolutize = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
socket2 = { workspace = true, features = ["all"] }
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use std::os::fd::FromRawFd as _;
|
|||
use std::os::fd::OwnedFd;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use crate::unix::escalate_protocol::ESCALATE_SOCKET_ENV_VAR;
|
||||
use crate::unix::escalate_protocol::EXEC_WRAPPER_ENV_VAR;
|
||||
|
|
@ -50,7 +51,7 @@ pub async fn run_shell_escalation_execve_wrapper(
|
|||
.send(EscalateRequest {
|
||||
file: file.clone().into(),
|
||||
argv: argv.clone(),
|
||||
workdir: std::env::current_dir()?,
|
||||
workdir: AbsolutePathBuf::current_dir()?,
|
||||
env,
|
||||
})
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use std::collections::HashMap;
|
|||
use std::os::fd::RawFd;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
|
|
@ -17,11 +18,14 @@ pub const LEGACY_BASH_EXEC_WRAPPER_ENV_VAR: &str = "BASH_EXEC_WRAPPER";
|
|||
/// The client sends this to the server to request an exec() call.
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
pub struct EscalateRequest {
|
||||
/// The absolute path to the executable to run, i.e. the first arg to exec.
|
||||
/// The executable path from the intercepted exec call.
|
||||
///
|
||||
/// This may be relative, in which case it should be resolved against
|
||||
/// `workdir`.
|
||||
pub file: PathBuf,
|
||||
/// The argv, including the program name (argv[0]).
|
||||
pub argv: Vec<String>,
|
||||
pub workdir: PathBuf,
|
||||
pub workdir: AbsolutePathBuf,
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use std::sync::Arc;
|
|||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use path_absolutize::Absolutize as _;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use tokio::process::Command;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
|
|
@ -114,8 +114,9 @@ impl EscalateServer {
|
|||
},
|
||||
params.command,
|
||||
];
|
||||
let workdir = AbsolutePathBuf::try_from(params.workdir)?;
|
||||
let result = command_executor
|
||||
.run(command, PathBuf::from(¶ms.workdir), env, cancel_rx)
|
||||
.run(command, workdir.to_path_buf(), env, cancel_rx)
|
||||
.await?;
|
||||
escalate_task.abort();
|
||||
Ok(result)
|
||||
|
|
@ -152,14 +153,13 @@ async fn handle_escalate_session_with_policy(
|
|||
workdir,
|
||||
env,
|
||||
} = socket.receive::<EscalateRequest>().await?;
|
||||
let file = PathBuf::from(&file).absolutize()?.into_owned();
|
||||
let workdir = PathBuf::from(&workdir).absolutize()?.into_owned();
|
||||
let program = AbsolutePathBuf::resolve_path_against_base(file, workdir.as_path())?;
|
||||
let action = policy
|
||||
.determine_action(file.as_path(), &argv, &workdir)
|
||||
.determine_action(&program, &argv, &workdir)
|
||||
.await
|
||||
.context("failed to determine escalation action")?;
|
||||
|
||||
tracing::debug!("decided {action:?} for {file:?} {argv:?} {workdir:?}");
|
||||
tracing::debug!("decided {action:?} for {program:?} {argv:?} {workdir:?}");
|
||||
|
||||
match action {
|
||||
EscalateAction::Run => {
|
||||
|
|
@ -197,7 +197,7 @@ async fn handle_escalate_session_with_policy(
|
|||
));
|
||||
}
|
||||
|
||||
let mut command = Command::new(file);
|
||||
let mut command = Command::new(program.as_path());
|
||||
command
|
||||
.args(&argv[1..])
|
||||
.arg0(argv[0].clone())
|
||||
|
|
@ -236,9 +236,9 @@ async fn handle_escalate_session_with_policy(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct DeterministicEscalationPolicy {
|
||||
|
|
@ -249,14 +249,33 @@ mod tests {
|
|||
impl EscalationPolicy for DeterministicEscalationPolicy {
|
||||
async fn determine_action(
|
||||
&self,
|
||||
_file: &Path,
|
||||
_file: &AbsolutePathBuf,
|
||||
_argv: &[String],
|
||||
_workdir: &Path,
|
||||
_workdir: &AbsolutePathBuf,
|
||||
) -> anyhow::Result<EscalateAction> {
|
||||
Ok(self.action.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct AssertingEscalationPolicy {
|
||||
expected_file: AbsolutePathBuf,
|
||||
expected_workdir: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl EscalationPolicy for AssertingEscalationPolicy {
|
||||
async fn determine_action(
|
||||
&self,
|
||||
file: &AbsolutePathBuf,
|
||||
_argv: &[String],
|
||||
workdir: &AbsolutePathBuf,
|
||||
) -> anyhow::Result<EscalateAction> {
|
||||
assert_eq!(file, &self.expected_file);
|
||||
assert_eq!(workdir, &self.expected_workdir);
|
||||
Ok(EscalateAction::Run)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_escalate_session_respects_run_in_sandbox_decision() -> anyhow::Result<()> {
|
||||
let (server, client) = AsyncSocket::pair()?;
|
||||
|
|
@ -277,7 +296,7 @@ mod tests {
|
|||
.send(EscalateRequest {
|
||||
file: PathBuf::from("/bin/echo"),
|
||||
argv: vec!["echo".to_string()],
|
||||
workdir: PathBuf::from("/tmp"),
|
||||
workdir: AbsolutePathBuf::try_from(PathBuf::from("/tmp"))?,
|
||||
env,
|
||||
})
|
||||
.await?;
|
||||
|
|
@ -292,6 +311,42 @@ mod tests {
|
|||
server_task.await?
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_escalate_session_resolves_relative_file_against_request_workdir()
|
||||
-> anyhow::Result<()> {
|
||||
let (server, client) = AsyncSocket::pair()?;
|
||||
let tmp = tempfile::TempDir::new()?;
|
||||
let workdir = tmp.path().join("workspace");
|
||||
std::fs::create_dir(&workdir)?;
|
||||
let workdir = AbsolutePathBuf::try_from(workdir)?;
|
||||
let expected_file = workdir.join("bin/tool")?;
|
||||
let server_task = tokio::spawn(handle_escalate_session_with_policy(
|
||||
server,
|
||||
Arc::new(AssertingEscalationPolicy {
|
||||
expected_file,
|
||||
expected_workdir: workdir.clone(),
|
||||
}),
|
||||
));
|
||||
|
||||
client
|
||||
.send(EscalateRequest {
|
||||
file: PathBuf::from("./bin/tool"),
|
||||
argv: vec!["./bin/tool".to_string()],
|
||||
workdir,
|
||||
env: HashMap::new(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response = client.receive::<EscalateResponse>().await?;
|
||||
assert_eq!(
|
||||
EscalateResponse {
|
||||
action: EscalateAction::Run,
|
||||
},
|
||||
response
|
||||
);
|
||||
server_task.await?
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_escalate_session_executes_escalated_command() -> anyhow::Result<()> {
|
||||
let (server, client) = AsyncSocket::pair()?;
|
||||
|
|
@ -310,7 +365,7 @@ mod tests {
|
|||
"-c".to_string(),
|
||||
r#"if [ "$KEY" = VALUE ]; then exit 42; else exit 1; fi"#.to_string(),
|
||||
],
|
||||
workdir: std::env::current_dir()?,
|
||||
workdir: AbsolutePathBuf::current_dir()?,
|
||||
env: HashMap::from([("KEY".to_string(), "VALUE".to_string())]),
|
||||
})
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use std::path::Path;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use crate::unix::escalate_protocol::EscalateAction;
|
||||
|
||||
|
|
@ -7,8 +7,8 @@ use crate::unix::escalate_protocol::EscalateAction;
|
|||
pub trait EscalationPolicy: Send + Sync {
|
||||
async fn determine_action(
|
||||
&self,
|
||||
file: &Path,
|
||||
file: &AbsolutePathBuf,
|
||||
argv: &[String],
|
||||
workdir: &Path,
|
||||
workdir: &AbsolutePathBuf,
|
||||
) -> anyhow::Result<EscalateAction>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue