Skip to main content

codex_execpolicy/
sandbox_migration.rs

1use std::collections::HashSet;
2use std::io;
3use std::io::SeekFrom;
4use std::io::Write as _;
5use std::path::Path;
6use tokio::io::AsyncReadExt;
7use tokio::io::AsyncSeekExt;
8use tokio::io::AsyncWriteExt;
9
10const MIGRATION_MARKER_FILENAME: &str = ".sandbox_migration";
11
12/// removes legacy allow rules that newer codex versions no longer offer.
13///
14/// this migration is intentionally one-shot. once complete, a marker in `codex_home` prevents
15/// policies saved by newer codex versions from being removed on later startups.
16pub async fn prefix_rule_migration(
17    codex_home: &Path,
18    policy_path: &Path,
19    banned_prefixes: &[&[&str]],
20) -> io::Result<()> {
21    let marker_path = codex_home.join(MIGRATION_MARKER_FILENAME);
22    if tokio::fs::try_exists(&marker_path).await? {
23        return Ok(());
24    }
25    clean_rules_file(policy_path, banned_prefixes).await?;
26
27    write_migration_marker(codex_home, &marker_path).await?;
28    Ok(())
29}
30
31// atomically writes the marker after creating codex home when needed.
32async fn write_migration_marker(codex_home: &Path, marker_path: &Path) -> io::Result<()> {
33    tokio::fs::create_dir_all(codex_home).await?;
34    let codex_home = codex_home.to_owned();
35    let marker_path = marker_path.to_owned();
36    tokio::task::spawn_blocking(move || {
37        let mut marker = tempfile::NamedTempFile::new_in(codex_home)?;
38        marker.write_all(b"v1\n")?;
39        match marker.persist_noclobber(marker_path) {
40            Ok(_) => Ok(()),
41            Err(err) if err.error.kind() == io::ErrorKind::AlreadyExists => Ok(()),
42            Err(err) => Err(err.error),
43        }
44    })
45    .await
46    .map_err(io::Error::other)?
47}
48
49// removes exact banned allow rules only when the policy needs changing.
50async fn clean_rules_file(policy_path: &Path, banned_prefixes: &[&[&str]]) -> io::Result<()> {
51    let contents = match tokio::fs::read_to_string(policy_path).await {
52        Ok(contents) => contents,
53        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
54        Err(err) => return Err(err),
55    };
56    if strip_banned_allow_rules(&contents, banned_prefixes) == contents {
57        return Ok(());
58    }
59
60    let mut file = match tokio::fs::OpenOptions::new()
61        .read(true)
62        .write(true)
63        .open(policy_path)
64        .await
65    {
66        Ok(file) => file,
67        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
68        Err(err) => return Err(err),
69    };
70
71    let mut contents = String::new();
72    file.read_to_string(&mut contents).await?;
73    let retained = strip_banned_allow_rules(&contents, banned_prefixes);
74    if retained == contents {
75        return Ok(());
76    }
77
78    file.seek(SeekFrom::Start(0)).await?;
79    file.write_all(retained.as_bytes()).await?;
80    file.set_len(retained.len() as u64).await?;
81    Ok(())
82}
83
84// returns the policy text without exact banned allow rules.
85fn strip_banned_allow_rules(contents: &str, banned_prefixes: &[&[&str]]) -> String {
86    let banned_prefixes = banned_prefixes
87        .iter()
88        .map(|prefix| {
89            prefix
90                .iter()
91                .map(|token| token.to_ascii_lowercase())
92                .collect::<Vec<_>>()
93        })
94        .collect::<HashSet<_>>();
95    contents
96        .split_inclusive('\n')
97        .filter(|line| !should_remove_rule(line, &banned_prefixes))
98        .collect()
99}
100
101// checks whether a line is an exact banned allow rule.
102fn should_remove_rule(line: &str, banned_prefixes: &HashSet<Vec<String>>) -> bool {
103    let line = line.strip_suffix('\n').unwrap_or(line);
104    let line = line.strip_suffix('\r').unwrap_or(line);
105    let Some(pattern) = line
106        .strip_prefix("prefix_rule(pattern=")
107        .and_then(|line| line.strip_suffix(r#", decision="allow")"#))
108    else {
109        return false;
110    };
111    let Ok(prefix) = serde_json::from_str::<Vec<String>>(pattern) else {
112        return false;
113    };
114    let prefix = prefix
115        .iter()
116        .map(|token| token.to_ascii_lowercase())
117        .collect::<Vec<_>>();
118    banned_prefixes.contains(&prefix)
119}
120
121#[cfg(test)]
122#[path = "sandbox_migration_tests.rs"]
123mod tests;