Skip to main content

codex_execpolicy/
amend.rs

1use std::fs::OpenOptions;
2use std::io::Read;
3use std::io::Seek;
4use std::io::SeekFrom;
5use std::io::Write;
6use std::path::Path;
7use std::path::PathBuf;
8
9use crate::decision::Decision;
10use crate::rule::NetworkRuleProtocol;
11use crate::rule::normalize_network_rule_host;
12use thiserror::Error;
13
14#[derive(Debug, Error)]
15pub enum AmendError {
16    #[error("prefix rule requires at least one token")]
17    EmptyPrefix,
18    #[error("invalid network rule: {0}")]
19    InvalidNetworkRule(String),
20    #[error("policy path has no parent: {path}")]
21    MissingParent { path: PathBuf },
22    #[error("failed to create policy directory {dir}: {source}")]
23    CreatePolicyDir {
24        dir: PathBuf,
25        source: std::io::Error,
26    },
27    #[error("failed to format prefix tokens: {source}")]
28    SerializePrefix { source: serde_json::Error },
29    #[error("failed to serialize network rule field: {source}")]
30    SerializeNetworkRule { source: serde_json::Error },
31    #[error("failed to open policy file {path}: {source}")]
32    OpenPolicyFile {
33        path: PathBuf,
34        source: std::io::Error,
35    },
36    #[error("failed to write to policy file {path}: {source}")]
37    WritePolicyFile {
38        path: PathBuf,
39        source: std::io::Error,
40    },
41    #[error("failed to lock policy file {path}: {source}")]
42    LockPolicyFile {
43        path: PathBuf,
44        source: std::io::Error,
45    },
46    #[error("failed to seek policy file {path}: {source}")]
47    SeekPolicyFile {
48        path: PathBuf,
49        source: std::io::Error,
50    },
51    #[error("failed to read policy file {path}: {source}")]
52    ReadPolicyFile {
53        path: PathBuf,
54        source: std::io::Error,
55    },
56    #[error("failed to read metadata for policy file {path}: {source}")]
57    PolicyMetadata {
58        path: PathBuf,
59        source: std::io::Error,
60    },
61}
62
63/// Note this thread uses advisory file locking and performs blocking I/O, so it should be used with
64/// [`tokio::task::spawn_blocking`] when called from an async context.
65pub fn blocking_append_allow_prefix_rule(
66    policy_path: &Path,
67    prefix: &[String],
68) -> Result<(), AmendError> {
69    if prefix.is_empty() {
70        return Err(AmendError::EmptyPrefix);
71    }
72
73    let tokens = prefix
74        .iter()
75        .map(serde_json::to_string)
76        .collect::<Result<Vec<_>, _>>()
77        .map_err(|source| AmendError::SerializePrefix { source })?;
78    let pattern = format!("[{}]", tokens.join(", "));
79    let rule = format!(r#"prefix_rule(pattern={pattern}, decision="allow")"#);
80    append_rule_line(policy_path, &rule)
81}
82
83/// Note this function uses advisory file locking and performs blocking I/O, so it should be used
84/// with [`tokio::task::spawn_blocking`] when called from an async context.
85pub fn blocking_append_network_rule(
86    policy_path: &Path,
87    host: &str,
88    protocol: NetworkRuleProtocol,
89    decision: Decision,
90    justification: Option<&str>,
91) -> Result<(), AmendError> {
92    let host = normalize_network_rule_host(host)
93        .map_err(|err| AmendError::InvalidNetworkRule(err.to_string()))?;
94    if let Some(raw) = justification
95        && raw.trim().is_empty()
96    {
97        return Err(AmendError::InvalidNetworkRule(
98            "justification cannot be empty".to_string(),
99        ));
100    }
101
102    let host = serde_json::to_string(&host)
103        .map_err(|source| AmendError::SerializeNetworkRule { source })?;
104    let protocol = serde_json::to_string(protocol.as_policy_string())
105        .map_err(|source| AmendError::SerializeNetworkRule { source })?;
106    let decision = serde_json::to_string(match decision {
107        Decision::Allow => "allow",
108        Decision::Prompt => "prompt",
109        Decision::Forbidden => "deny",
110    })
111    .map_err(|source| AmendError::SerializeNetworkRule { source })?;
112
113    let mut args = vec![
114        format!("host={host}"),
115        format!("protocol={protocol}"),
116        format!("decision={decision}"),
117    ];
118    if let Some(justification) = justification {
119        let justification = serde_json::to_string(justification)
120            .map_err(|source| AmendError::SerializeNetworkRule { source })?;
121        args.push(format!("justification={justification}"));
122    }
123    let rule = format!("network_rule({})", args.join(", "));
124    append_rule_line(policy_path, &rule)
125}
126
127fn append_rule_line(policy_path: &Path, rule: &str) -> Result<(), AmendError> {
128    let dir = policy_path
129        .parent()
130        .ok_or_else(|| AmendError::MissingParent {
131            path: policy_path.to_path_buf(),
132        })?;
133    match std::fs::create_dir(dir) {
134        Ok(()) => {}
135        Err(ref source) if source.kind() == std::io::ErrorKind::AlreadyExists => {}
136        Err(source) => {
137            return Err(AmendError::CreatePolicyDir {
138                dir: dir.to_path_buf(),
139                source,
140            });
141        }
142    }
143
144    append_locked_line(policy_path, rule)
145}
146
147fn append_locked_line(policy_path: &Path, line: &str) -> Result<(), AmendError> {
148    let mut file = OpenOptions::new()
149        .create(true)
150        .read(true)
151        .append(true)
152        .open(policy_path)
153        .map_err(|source| AmendError::OpenPolicyFile {
154            path: policy_path.to_path_buf(),
155            source,
156        })?;
157    file.lock().map_err(|source| AmendError::LockPolicyFile {
158        path: policy_path.to_path_buf(),
159        source,
160    })?;
161
162    file.seek(SeekFrom::Start(0))
163        .map_err(|source| AmendError::SeekPolicyFile {
164            path: policy_path.to_path_buf(),
165            source,
166        })?;
167    let mut contents = String::new();
168    file.read_to_string(&mut contents)
169        .map_err(|source| AmendError::ReadPolicyFile {
170            path: policy_path.to_path_buf(),
171            source,
172        })?;
173
174    if contents.lines().any(|existing| existing == line) {
175        return Ok(());
176    }
177
178    if !contents.is_empty() && !contents.ends_with('\n') {
179        file.write_all(b"\n")
180            .map_err(|source| AmendError::WritePolicyFile {
181                path: policy_path.to_path_buf(),
182                source,
183            })?;
184    }
185
186    file.write_all(format!("{line}\n").as_bytes())
187        .map_err(|source| AmendError::WritePolicyFile {
188            path: policy_path.to_path_buf(),
189            source,
190        })?;
191
192    Ok(())
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use pretty_assertions::assert_eq;
199    use tempfile::tempdir;
200
201    #[test]
202    fn appends_rule_and_creates_directories() {
203        let tmp = tempdir().expect("create temp dir");
204        let policy_path = tmp.path().join("rules").join("default.rules");
205
206        blocking_append_allow_prefix_rule(
207            &policy_path,
208            &[String::from("echo"), String::from("Hello, world!")],
209        )
210        .expect("append rule");
211
212        let contents = std::fs::read_to_string(&policy_path).expect("default.rules should exist");
213        assert_eq!(
214            contents,
215            r#"prefix_rule(pattern=["echo", "Hello, world!"], decision="allow")
216"#
217        );
218    }
219
220    #[test]
221    fn appends_rule_without_duplicate_newline() {
222        let tmp = tempdir().expect("create temp dir");
223        let policy_path = tmp.path().join("rules").join("default.rules");
224        std::fs::create_dir_all(policy_path.parent().unwrap()).expect("create policy dir");
225        std::fs::write(
226            &policy_path,
227            r#"prefix_rule(pattern=["ls"], decision="allow")
228"#,
229        )
230        .expect("write seed rule");
231
232        blocking_append_allow_prefix_rule(
233            &policy_path,
234            &[String::from("echo"), String::from("Hello, world!")],
235        )
236        .expect("append rule");
237
238        let contents = std::fs::read_to_string(&policy_path).expect("read policy");
239        assert_eq!(
240            contents,
241            r#"prefix_rule(pattern=["ls"], decision="allow")
242prefix_rule(pattern=["echo", "Hello, world!"], decision="allow")
243"#
244        );
245    }
246
247    #[test]
248    fn inserts_newline_when_missing_before_append() {
249        let tmp = tempdir().expect("create temp dir");
250        let policy_path = tmp.path().join("rules").join("default.rules");
251        std::fs::create_dir_all(policy_path.parent().unwrap()).expect("create policy dir");
252        std::fs::write(
253            &policy_path,
254            r#"prefix_rule(pattern=["ls"], decision="allow")"#,
255        )
256        .expect("write seed rule without newline");
257
258        blocking_append_allow_prefix_rule(
259            &policy_path,
260            &[String::from("echo"), String::from("Hello, world!")],
261        )
262        .expect("append rule");
263
264        let contents = std::fs::read_to_string(&policy_path).expect("read policy");
265        assert_eq!(
266            contents,
267            r#"prefix_rule(pattern=["ls"], decision="allow")
268prefix_rule(pattern=["echo", "Hello, world!"], decision="allow")
269"#
270        );
271    }
272
273    #[test]
274    fn appends_network_rule() {
275        let tmp = tempdir().expect("create temp dir");
276        let policy_path = tmp.path().join("rules").join("default.rules");
277
278        blocking_append_network_rule(
279            &policy_path,
280            "Api.GitHub.com",
281            NetworkRuleProtocol::Https,
282            Decision::Allow,
283            Some("Allow https_connect access to api.github.com"),
284        )
285        .expect("append network rule");
286
287        let contents = std::fs::read_to_string(&policy_path).expect("read policy");
288        assert_eq!(
289            contents,
290            r#"network_rule(host="api.github.com", protocol="https", decision="allow", justification="Allow https_connect access to api.github.com")
291"#
292        );
293    }
294
295    #[test]
296    fn appends_prefix_and_network_rules() {
297        let tmp = tempdir().expect("create temp dir");
298        let policy_path = tmp.path().join("rules").join("default.rules");
299
300        blocking_append_allow_prefix_rule(&policy_path, &[String::from("curl")])
301            .expect("append prefix rule");
302        blocking_append_network_rule(
303            &policy_path,
304            "api.github.com",
305            NetworkRuleProtocol::Https,
306            Decision::Allow,
307            Some("Allow https_connect access to api.github.com"),
308        )
309        .expect("append network rule");
310
311        let contents = std::fs::read_to_string(&policy_path).expect("read policy");
312        assert_eq!(
313            contents,
314            r#"prefix_rule(pattern=["curl"], decision="allow")
315network_rule(host="api.github.com", protocol="https", decision="allow", justification="Allow https_connect access to api.github.com")
316"#
317        );
318    }
319
320    #[test]
321    fn rejects_wildcard_network_rule_host() {
322        let tmp = tempdir().expect("create temp dir");
323        let policy_path = tmp.path().join("rules").join("default.rules");
324        let err = blocking_append_network_rule(
325            &policy_path,
326            "*.example.com",
327            NetworkRuleProtocol::Https,
328            Decision::Allow,
329            /*justification*/ None,
330        )
331        .expect_err("wildcards should be rejected");
332        assert_eq!(
333            err.to_string(),
334            "invalid network rule: invalid rule: network_rule host must be a specific host; wildcards are not allowed"
335        );
336    }
337}