Migrate legacy exec policy allow rules (#34271)

## What changed

- On session startup, remove exact `allow` entries from `rules/default.rules`
  for command prefixes that Codex no longer suggests as policy amendments.
- Record the migration in `.sandbox_migration` so it runs only once, preserving
  rules created after the migration.
- Skip the migration when user and project exec policy rules are ignored.
- Expand the protected prefix list across shells, interpreters, package runners,
  and destructive or privilege-related commands.

## Testing

- Cover selective removal, case-insensitive matching, one-time behavior, and the
  startup path with ignored policy rules.

GitOrigin-RevId: a0c60e3f82b9630e621fd034b40462e3ab775102
This commit is contained in:
Won Park
2026-07-20 06:09:26 +00:00
committed by copyberry
parent 678157acaa
commit bf3c1972b7
10 changed files with 336 additions and 39 deletions

View File

@@ -26,8 +26,9 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
shlex = { workspace = true }
starlark = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs", "io-util", "macros", "rt"] }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }

View File

@@ -6,6 +6,7 @@ mod executable_name;
pub(crate) mod parser;
pub(crate) mod policy;
pub mod rule;
mod sandbox_migration;
pub use amend::AmendError;
pub use amend::blocking_append_allow_prefix_rule;
@@ -28,3 +29,4 @@ pub use rule::PrefixRule;
pub use rule::Rule;
pub use rule::RuleMatch;
pub use rule::RuleRef;
pub use sandbox_migration::prefix_rule_migration;

View File

@@ -0,0 +1,123 @@
use std::collections::HashSet;
use std::io;
use std::io::SeekFrom;
use std::io::Write as _;
use std::path::Path;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncSeekExt;
use tokio::io::AsyncWriteExt;
const MIGRATION_MARKER_FILENAME: &str = ".sandbox_migration";
/// removes legacy allow rules that newer codex versions no longer offer.
///
/// this migration is intentionally one-shot. once complete, a marker in `codex_home` prevents
/// policies saved by newer codex versions from being removed on later startups.
pub async fn prefix_rule_migration(
codex_home: &Path,
policy_path: &Path,
banned_prefixes: &[&[&str]],
) -> io::Result<()> {
let marker_path = codex_home.join(MIGRATION_MARKER_FILENAME);
if tokio::fs::try_exists(&marker_path).await? {
return Ok(());
}
clean_rules_file(policy_path, banned_prefixes).await?;
write_migration_marker(codex_home, &marker_path).await?;
Ok(())
}
// atomically writes the marker after creating codex home when needed.
async fn write_migration_marker(codex_home: &Path, marker_path: &Path) -> io::Result<()> {
tokio::fs::create_dir_all(codex_home).await?;
let codex_home = codex_home.to_owned();
let marker_path = marker_path.to_owned();
tokio::task::spawn_blocking(move || {
let mut marker = tempfile::NamedTempFile::new_in(codex_home)?;
marker.write_all(b"v1\n")?;
match marker.persist_noclobber(marker_path) {
Ok(_) => Ok(()),
Err(err) if err.error.kind() == io::ErrorKind::AlreadyExists => Ok(()),
Err(err) => Err(err.error),
}
})
.await
.map_err(io::Error::other)?
}
// removes exact banned allow rules only when the policy needs changing.
async fn clean_rules_file(policy_path: &Path, banned_prefixes: &[&[&str]]) -> io::Result<()> {
let contents = match tokio::fs::read_to_string(policy_path).await {
Ok(contents) => contents,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err),
};
if strip_banned_allow_rules(&contents, banned_prefixes) == contents {
return Ok(());
}
let mut file = match tokio::fs::OpenOptions::new()
.read(true)
.write(true)
.open(policy_path)
.await
{
Ok(file) => file,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err),
};
let mut contents = String::new();
file.read_to_string(&mut contents).await?;
let retained = strip_banned_allow_rules(&contents, banned_prefixes);
if retained == contents {
return Ok(());
}
file.seek(SeekFrom::Start(0)).await?;
file.write_all(retained.as_bytes()).await?;
file.set_len(retained.len() as u64).await?;
Ok(())
}
// returns the policy text without exact banned allow rules.
fn strip_banned_allow_rules(contents: &str, banned_prefixes: &[&[&str]]) -> String {
let banned_prefixes = banned_prefixes
.iter()
.map(|prefix| {
prefix
.iter()
.map(|token| token.to_ascii_lowercase())
.collect::<Vec<_>>()
})
.collect::<HashSet<_>>();
contents
.split_inclusive('\n')
.filter(|line| !should_remove_rule(line, &banned_prefixes))
.collect()
}
// checks whether a line is an exact banned allow rule.
fn should_remove_rule(line: &str, banned_prefixes: &HashSet<Vec<String>>) -> bool {
let line = line.strip_suffix('\n').unwrap_or(line);
let line = line.strip_suffix('\r').unwrap_or(line);
let Some(pattern) = line
.strip_prefix("prefix_rule(pattern=")
.and_then(|line| line.strip_suffix(r#", decision="allow")"#))
else {
return false;
};
let Ok(prefix) = serde_json::from_str::<Vec<String>>(pattern) else {
return false;
};
let prefix = prefix
.iter()
.map(|token| token.to_ascii_lowercase())
.collect::<Vec<_>>();
banned_prefixes.contains(&prefix)
}
#[cfg(test)]
#[path = "sandbox_migration_tests.rs"]
mod tests;

View File

@@ -0,0 +1,58 @@
use super::*;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[tokio::test]
async fn removes_banned_allow_rules_once() {
const BANNED_PREFIXES: &[&[&str]] = &[
&["cmd.exe", "/k"],
&["git"],
&["pwsh", "-ec"],
&["pwsh", "-f"],
];
let codex_home = tempdir().expect("create codex home");
let policy_path = codex_home.path().join("rules/default.rules");
std::fs::create_dir_all(policy_path.parent().expect("rules directory"))
.expect("create rules directory");
std::fs::write(
&policy_path,
r#"prefix_rule(pattern=["git"], decision="allow")
prefix_rule(pattern=["git"], decision="prompt")
prefix_rule(pattern=["git"], decision="deny")
prefix_rule(pattern=["git", "status"], decision="allow")
prefix_rule(pattern=["CMD.EXE", "/K"], decision="allow")
prefix_rule(pattern=["PWSH", "-EC"], decision="allow")
prefix_rule(pattern=["PwSh", "-F"], decision="allow")
network_rule(host="api.github.com", protocol="https", decision="allow")
"#,
)
.expect("write legacy policy");
prefix_rule_migration(codex_home.path(), &policy_path, BANNED_PREFIXES)
.await
.expect("run sandbox migration");
assert_eq!(
std::fs::read_to_string(&policy_path).expect("read migrated policy"),
r#"prefix_rule(pattern=["git"], decision="prompt")
prefix_rule(pattern=["git"], decision="deny")
prefix_rule(pattern=["git", "status"], decision="allow")
network_rule(host="api.github.com", protocol="https", decision="allow")
"#
);
assert_eq!(
std::fs::read_to_string(codex_home.path().join(MIGRATION_MARKER_FILENAME))
.expect("read migration marker"),
"v1\n"
);
let post_migration_policy = r#"prefix_rule(pattern=["git"], decision="allow")
"#;
std::fs::write(&policy_path, post_migration_policy).expect("write post-migration policy");
prefix_rule_migration(codex_home.path(), &policy_path, BANNED_PREFIXES)
.await
.expect("rerun sandbox migration");
assert_eq!(
std::fs::read_to_string(&policy_path).expect("read post-migration policy"),
post_migration_policy
);
}