Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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 }
150 changes: 150 additions & 0 deletions codex-rs/execpolicy/src/amend.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
use std::fs::OpenOptions;
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 token {token}: {source}")]
SerializeToken {
token: String,
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 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 tokens: Vec<String> = prefix
.iter()
.map(|token| {
serde_json::to_string(token).map_err(|source| AmendError::SerializeToken {
token: token.clone(),
source,
})
})
.collect::<Result<_, _>>()?;
let pattern = tokens.join(", ");
let rule = format!("prefix_rule(pattern=[{pattern}], decision=\"allow\")\n");

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,
});
}
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(policy_path)
.map_err(|source| AmendError::OpenPolicyFile {
path: policy_path.to_path_buf(),
source,
})?;
let needs_newline = file
.metadata()
.map(|metadata| metadata.len() > 0)
.map_err(|source| AmendError::PolicyMetadata {
path: policy_path.to_path_buf(),
source,
})?;
let final_rule = if needs_newline {
format!("\n{rule}")
} else {
rule
};

file.write_all(final_rule.as_bytes())
.map_err(|source| AmendError::WritePolicyFile {
path: policy_path.to_path_buf(),
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 separates_rules_with_newlines_when_appending() {
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\")\n\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