Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions codex-rs/execpolicy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ thiserror = { workspace = true }

[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
221 changes: 221 additions & 0 deletions codex-rs/execpolicy/src/amend.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
use std::fs::OpenOptions;
use std::io::Read;
use std::io::Seek;
use std::io::SeekFrom;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;

use serde_json;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum AmendError {
#[error("prefix rule requires at least one token")]
EmptyPrefix,
#[error("policy path has no parent: {path}")]
MissingParent { path: PathBuf },
#[error("failed to create policy directory {dir}: {source}")]
CreatePolicyDir {
dir: PathBuf,
source: std::io::Error,
},
#[error("failed to format prefix tokens: {source}")]
SerializePrefix { source: serde_json::Error },
#[error("failed to open policy file {path}: {source}")]
OpenPolicyFile {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to write to policy file {path}: {source}")]
WritePolicyFile {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to lock policy file {path}: {source}")]
LockPolicyFile {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to unlock policy file {path}: {source}")]
UnlockPolicyFile {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to seek policy file {path}: {source}")]
SeekPolicyFile {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to read policy file {path}: {source}")]
ReadPolicyFile {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to read metadata for policy file {path}: {source}")]
PolicyMetadata {
path: PathBuf,
source: std::io::Error,
},
}

pub fn append_allow_prefix_rule(policy_path: &Path, prefix: &[String]) -> Result<(), AmendError> {
if prefix.is_empty() {
return Err(AmendError::EmptyPrefix);
}

let pattern =
serde_json::to_string(prefix).map_err(|source| AmendError::SerializePrefix { source })?;
let rule = format!("prefix_rule(pattern={pattern}, decision=\"allow\")");

let dir = policy_path
.parent()
.ok_or_else(|| AmendError::MissingParent {
path: policy_path.to_path_buf(),
})?;
match std::fs::create_dir(dir) {
Ok(()) => {}
Err(ref source) if source.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(source) => {
return Err(AmendError::CreatePolicyDir {
dir: dir.to_path_buf(),
source,
});
}
}
append_locked_line(policy_path, &rule)
}

fn append_locked_line(policy_path: &Path, line: &str) -> Result<(), AmendError> {
let policy_path = policy_path.to_path_buf();
let mut file = OpenOptions::new()
.create(true)
.read(true)
.append(true)
.open(&policy_path)
.map_err(|source| AmendError::OpenPolicyFile {
path: policy_path.clone(),
source,
})?;
file.lock().map_err(|source| AmendError::LockPolicyFile {
path: policy_path.clone(),
source,
})?;

let len = file
.metadata()
.map_err(|source| AmendError::PolicyMetadata {
path: policy_path.clone(),
source,
})?
.len();

if len > 0 {
file.seek(SeekFrom::End(-1))
.map_err(|source| AmendError::SeekPolicyFile {
path: policy_path.clone(),
source,
})?;
let mut last = [0; 1];
file.read_exact(&mut last)
.map_err(|source| AmendError::ReadPolicyFile {
path: policy_path.clone(),
source,
})?;

if last[0] != b'\n' {
file.write_all(b"\n")
.map_err(|source| AmendError::WritePolicyFile {
path: policy_path.clone(),
source,
})?;
}
}

file.write_all(line.as_bytes())
.and_then(|()| file.write_all(b"\n"))
.map_err(|source| AmendError::WritePolicyFile {
path: policy_path.clone(),
source,
})?;

file.unlock()
.map_err(|source| AmendError::UnlockPolicyFile {
path: policy_path,
source,
})
}

#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::tempdir;

#[test]
fn appends_rule_and_creates_directories() {
let tmp = tempdir().expect("create temp dir");
let policy_path = tmp.path().join("policy").join("default.codexpolicy");

append_allow_prefix_rule(
&policy_path,
&[String::from("echo"), String::from("Hello, world!")],
)
.expect("append rule");

let contents =
std::fs::read_to_string(&policy_path).expect("default.codexpolicy should exist");
assert_eq!(
contents,
"prefix_rule(pattern=[\"echo\",\"Hello, world!\"], decision=\"allow\")\n"
);
}

#[test]
fn appends_rule_without_duplicate_newline() {
let tmp = tempdir().expect("create temp dir");
let policy_path = tmp.path().join("policy").join("default.codexpolicy");
std::fs::create_dir_all(policy_path.parent().unwrap()).expect("create policy dir");
std::fs::write(
&policy_path,
"prefix_rule(pattern=[\"ls\"], decision=\"allow\")\n",
)
.expect("write seed rule");

append_allow_prefix_rule(
&policy_path,
&[String::from("echo"), String::from("Hello, world!")],
)
.expect("append rule");

let contents = std::fs::read_to_string(&policy_path).expect("read policy");
assert_eq!(
contents,
"prefix_rule(pattern=[\"ls\"], decision=\"allow\")\nprefix_rule(pattern=[\"echo\",\"Hello, world!\"], decision=\"allow\")\n"
);
}

#[test]
fn inserts_newline_when_missing_before_append() {
let tmp = tempdir().expect("create temp dir");
let policy_path = tmp.path().join("policy").join("default.codexpolicy");
std::fs::create_dir_all(policy_path.parent().unwrap()).expect("create policy dir");
std::fs::write(
&policy_path,
"prefix_rule(pattern=[\"ls\"], decision=\"allow\")",
)
.expect("write seed rule without newline");

append_allow_prefix_rule(
&policy_path,
&[String::from("echo"), String::from("Hello, world!")],
)
.expect("append rule");

let contents = std::fs::read_to_string(&policy_path).expect("read policy");
assert_eq!(
contents,
"prefix_rule(pattern=[\"ls\"], decision=\"allow\")\nprefix_rule(pattern=[\"echo\",\"Hello, world!\"], decision=\"allow\")\n"
);
}
}
3 changes: 3 additions & 0 deletions codex-rs/execpolicy/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
pub mod amend;
pub mod decision;
pub mod error;
pub mod parser;
pub mod policy;
pub mod rule;

pub use amend::AmendError;
pub use amend::append_allow_prefix_rule;
pub use decision::Decision;
pub use error::Error;
pub use error::Result;
Expand Down
27 changes: 27 additions & 0 deletions codex-rs/execpolicy/src/policy.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
use crate::decision::Decision;
use crate::error::Error;
use crate::error::Result;
use crate::rule::PatternToken;
use crate::rule::PrefixPattern;
use crate::rule::PrefixRule;
use crate::rule::RuleMatch;
use crate::rule::RuleRef;
use multimap::MultiMap;
use serde::Deserialize;
use serde::Serialize;
use std::sync::Arc;

#[derive(Clone, Debug)]
pub struct Policy {
Expand All @@ -23,6 +29,27 @@ impl Policy {
&self.rules_by_program
}

pub fn add_prefix_rule(&mut self, prefix: &[String], decision: Decision) -> Result<()> {
let (first_token, rest) = prefix
.split_first()
.ok_or_else(|| Error::InvalidPattern("prefix cannot be empty".to_string()))?;

let rule: RuleRef = Arc::new(PrefixRule {
pattern: PrefixPattern {
first: Arc::from(first_token.as_str()),
rest: rest
.iter()
.map(|token| PatternToken::Single(token.clone()))
.collect::<Vec<_>>()
.into(),
},
decision,
});

self.rules_by_program.insert(first_token.clone(), rule);
Ok(())
}

pub fn check(&self, cmd: &[String]) -> Evaluation {
let rules = match cmd.first() {
Some(first) => match self.rules_by_program.get_vec(first) {
Expand Down
45 changes: 45 additions & 0 deletions codex-rs/execpolicy/tests/basic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ use std::any::Any;
use std::sync::Arc;

use codex_execpolicy::Decision;
use codex_execpolicy::Error;
use codex_execpolicy::Evaluation;
use codex_execpolicy::Policy;
use codex_execpolicy::PolicyParser;
use codex_execpolicy::RuleMatch;
use codex_execpolicy::RuleRef;
Expand Down Expand Up @@ -60,6 +62,49 @@ prefix_rule(
);
}

#[test]
fn add_prefix_rule_extends_policy() {
let mut policy = Policy::empty();
policy
.add_prefix_rule(&tokens(&["ls", "-l"]), Decision::Prompt)
.expect("add prefix rule");

let rules = rule_snapshots(policy.rules().get_vec("ls").expect("ls rules"));
assert_eq!(
vec![RuleSnapshot::Prefix(PrefixRule {
pattern: PrefixPattern {
first: Arc::from("ls"),
rest: vec![PatternToken::Single(String::from("-l"))].into(),
},
decision: Decision::Prompt,
})],
rules
);

let evaluation = policy.check(&tokens(&["ls", "-l", "/tmp"]));
assert_eq!(
Evaluation::Match {
decision: Decision::Prompt,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["ls", "-l"]),
decision: Decision::Prompt,
}],
},
evaluation
);
}

#[test]
fn add_prefix_rule_rejects_empty_prefix() {
let mut policy = Policy::empty();
let result = policy.add_prefix_rule(&[], Decision::Allow);

assert!(matches!(
result,
Err(Error::InvalidPattern(message)) if message == "prefix cannot be empty"
));
}

#[test]
fn parses_multiple_policy_files() {
let first_policy = r#"
Expand Down
Loading