mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
## What changed - Add `StartThreadOptions` setters for captured thread instructions and execution policy snapshots. Captured instructions replace the live thread provider; empty text clears the thread contribution. Hosts must supply inherited settings again on cold resume. - Install inherited policies in independent managers only when the destination ignores user and project rules, has no additional policy files, and has matching managed requirements. Preserve managed command restrictions, host executable mappings, and network denials, including restrictions that absolute-path rules could otherwise bypass. - Reject inherited startup settings for isolated sessions and reject combining local and transported execution policies. ## Testing Add coverage for policy inheritance through children and grandchildren, incompatible or weakened policies, absolute-path amendments, and captured instructions overriding or clearing live instructions on startup and resume. An execution integration test checks that an inherited allow rule runs without approval while a managed denial still blocks execution. GitOrigin-RevId: c1147992fe5bb5ba4bcf981c08fcfcb6f6bca8a1
504 lines
16 KiB
Rust
504 lines
16 KiB
Rust
use crate::decision::Decision;
|
|
use crate::error::Error;
|
|
use crate::error::Result;
|
|
use crate::executable_name::executable_path_lookup_key;
|
|
use crate::rule::NetworkRule;
|
|
use crate::rule::NetworkRuleProtocol;
|
|
use crate::rule::PatternToken;
|
|
use crate::rule::PrefixPattern;
|
|
use crate::rule::PrefixRule;
|
|
use crate::rule::RuleMatch;
|
|
use crate::rule::RuleRef;
|
|
use crate::rule::normalize_network_rule_host;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use multimap::MultiMap;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
type HeuristicsFallback<'a> = Option<&'a dyn Fn(&[String]) -> Decision>;
|
|
|
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
|
pub struct MatchOptions {
|
|
pub resolve_host_executables: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Policy {
|
|
rules_by_program: MultiMap<String, RuleRef>,
|
|
network_rules: Vec<NetworkRule>,
|
|
host_executables_by_name: HashMap<String, Arc<[AbsolutePathBuf]>>,
|
|
}
|
|
|
|
/// A requirements-owned execution policy with order-independent equality.
|
|
#[derive(Debug, Clone)]
|
|
pub struct RequirementsExecPolicy {
|
|
policy: Policy,
|
|
}
|
|
|
|
impl RequirementsExecPolicy {
|
|
pub fn new(policy: Policy) -> Self {
|
|
Self { policy }
|
|
}
|
|
|
|
pub fn fingerprint(&self) -> Vec<String> {
|
|
let mut entries = Vec::new();
|
|
for (program, rules) in self.policy.rules().iter_all() {
|
|
for rule in rules {
|
|
entries.push(format!("{program}:{rule:?}"));
|
|
}
|
|
}
|
|
entries.sort();
|
|
entries
|
|
}
|
|
}
|
|
|
|
impl PartialEq for RequirementsExecPolicy {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.fingerprint() == other.fingerprint()
|
|
}
|
|
}
|
|
|
|
impl Eq for RequirementsExecPolicy {}
|
|
|
|
impl AsRef<Policy> for RequirementsExecPolicy {
|
|
fn as_ref(&self) -> &Policy {
|
|
&self.policy
|
|
}
|
|
}
|
|
|
|
impl Policy {
|
|
pub fn new(rules_by_program: MultiMap<String, RuleRef>) -> Self {
|
|
Self::from_parts(rules_by_program, Vec::new(), HashMap::new())
|
|
}
|
|
|
|
pub fn from_parts(
|
|
rules_by_program: MultiMap<String, RuleRef>,
|
|
network_rules: Vec<NetworkRule>,
|
|
host_executables_by_name: HashMap<String, Arc<[AbsolutePathBuf]>>,
|
|
) -> Self {
|
|
Self {
|
|
rules_by_program,
|
|
network_rules,
|
|
host_executables_by_name,
|
|
}
|
|
}
|
|
|
|
pub fn empty() -> Self {
|
|
Self::new(MultiMap::new())
|
|
}
|
|
|
|
pub fn rules(&self) -> &MultiMap<String, RuleRef> {
|
|
&self.rules_by_program
|
|
}
|
|
|
|
pub fn network_rules(&self) -> &[NetworkRule] {
|
|
&self.network_rules
|
|
}
|
|
|
|
pub fn host_executables(&self) -> &HashMap<String, Arc<[AbsolutePathBuf]>> {
|
|
&self.host_executables_by_name
|
|
}
|
|
|
|
pub fn get_allowed_prefixes(&self) -> Vec<Vec<String>> {
|
|
let mut prefixes = Vec::new();
|
|
|
|
for (_program, rules) in self.rules_by_program.iter_all() {
|
|
for rule in rules {
|
|
let Some(prefix_rule) = rule.as_any().downcast_ref::<PrefixRule>() else {
|
|
continue;
|
|
};
|
|
if prefix_rule.decision != Decision::Allow {
|
|
continue;
|
|
}
|
|
|
|
let mut prefix = Vec::with_capacity(prefix_rule.pattern.rest.len() + 1);
|
|
prefix.push(prefix_rule.pattern.first.as_ref().to_string());
|
|
prefix.extend(prefix_rule.pattern.rest.iter().map(render_pattern_token));
|
|
prefixes.push(prefix);
|
|
}
|
|
}
|
|
|
|
prefixes.sort();
|
|
prefixes.dedup();
|
|
prefixes
|
|
}
|
|
|
|
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,
|
|
justification: None,
|
|
});
|
|
|
|
self.rules_by_program.insert(first_token.clone(), rule);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn add_network_rule(
|
|
&mut self,
|
|
host: &str,
|
|
protocol: NetworkRuleProtocol,
|
|
decision: Decision,
|
|
justification: Option<String>,
|
|
) -> Result<()> {
|
|
let host = normalize_network_rule_host(host)?;
|
|
if let Some(raw) = justification.as_deref()
|
|
&& raw.trim().is_empty()
|
|
{
|
|
return Err(Error::InvalidRule(
|
|
"justification cannot be empty".to_string(),
|
|
));
|
|
}
|
|
self.network_rules.push(NetworkRule {
|
|
host,
|
|
protocol,
|
|
decision,
|
|
justification,
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
pub fn set_host_executable_paths(&mut self, name: String, paths: Vec<AbsolutePathBuf>) {
|
|
self.host_executables_by_name.insert(name, paths.into());
|
|
}
|
|
|
|
/// Check that a transported snapshot still enforces the destination's managed rules.
|
|
/// Exact path matches can suppress basename matching, and a later network grant can
|
|
/// override a managed denial for the same host.
|
|
pub fn preserves_managed_requirements(&self, requirements: &RequirementsExecPolicy) -> bool {
|
|
let managed = requirements.as_ref();
|
|
if self.host_executables() != managed.host_executables() {
|
|
return false;
|
|
}
|
|
for (program, rules) in managed.rules().iter_all() {
|
|
if rules
|
|
.iter()
|
|
.any(|rule| !self.contains_prefix_rule(program, rule))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
for (program, rules) in self.rules().iter_all() {
|
|
let Ok(path) = AbsolutePathBuf::try_from(program.as_str()) else {
|
|
continue;
|
|
};
|
|
if let Some((_, fallback_rules)) = managed.host_executable_rules(&path)
|
|
&& rules.iter().any(|rule| {
|
|
!managed.contains_prefix_rule(program, rule)
|
|
&& fallback_rules
|
|
.iter()
|
|
.any(|fallback| can_weaken_fallback(rule, fallback, rules))
|
|
})
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
let (_, required_denials) = managed.compiled_network_domains();
|
|
let (_, snapshot_denials) = self.compiled_network_domains();
|
|
required_denials
|
|
.iter()
|
|
.all(|host| snapshot_denials.contains(host))
|
|
}
|
|
|
|
fn contains_prefix_rule(&self, program: &str, rule: &RuleRef) -> bool {
|
|
let Some(prefix) = rule.as_any().downcast_ref::<PrefixRule>() else {
|
|
return false;
|
|
};
|
|
self.rules().get_vec(program).is_some_and(|rules| {
|
|
rules
|
|
.iter()
|
|
.any(|candidate| candidate.as_any().downcast_ref::<PrefixRule>() == Some(prefix))
|
|
})
|
|
}
|
|
|
|
pub fn merge_overlay(&self, overlay: &Policy) -> Policy {
|
|
let mut combined_rules = self.rules_by_program.clone();
|
|
for (program, rules) in overlay.rules_by_program.iter_all() {
|
|
for rule in rules {
|
|
combined_rules.insert(program.clone(), rule.clone());
|
|
}
|
|
}
|
|
|
|
let mut combined_network_rules = self.network_rules.clone();
|
|
combined_network_rules.extend(overlay.network_rules.iter().cloned());
|
|
|
|
let mut host_executables_by_name = self.host_executables_by_name.clone();
|
|
host_executables_by_name.extend(
|
|
overlay
|
|
.host_executables_by_name
|
|
.iter()
|
|
.map(|(name, paths)| (name.clone(), paths.clone())),
|
|
);
|
|
|
|
Policy::from_parts(
|
|
combined_rules,
|
|
combined_network_rules,
|
|
host_executables_by_name,
|
|
)
|
|
}
|
|
|
|
pub fn compiled_network_domains(&self) -> (Vec<String>, Vec<String>) {
|
|
let mut allowed = Vec::new();
|
|
let mut denied = Vec::new();
|
|
|
|
for rule in &self.network_rules {
|
|
match rule.decision {
|
|
Decision::Allow => {
|
|
denied.retain(|entry| entry != &rule.host);
|
|
upsert_domain(&mut allowed, &rule.host);
|
|
}
|
|
Decision::Forbidden => {
|
|
allowed.retain(|entry| entry != &rule.host);
|
|
upsert_domain(&mut denied, &rule.host);
|
|
}
|
|
Decision::Prompt | Decision::PromptUser => {}
|
|
}
|
|
}
|
|
|
|
(allowed, denied)
|
|
}
|
|
|
|
pub fn check<F>(&self, cmd: &[String], heuristics_fallback: &F) -> Evaluation
|
|
where
|
|
F: Fn(&[String]) -> Decision,
|
|
{
|
|
let matched_rules = self.matches_for_command_with_options(
|
|
cmd,
|
|
Some(heuristics_fallback),
|
|
&MatchOptions::default(),
|
|
);
|
|
Evaluation::from_matches(matched_rules)
|
|
}
|
|
|
|
pub fn check_with_options<F>(
|
|
&self,
|
|
cmd: &[String],
|
|
heuristics_fallback: &F,
|
|
options: &MatchOptions,
|
|
) -> Evaluation
|
|
where
|
|
F: Fn(&[String]) -> Decision,
|
|
{
|
|
let matched_rules =
|
|
self.matches_for_command_with_options(cmd, Some(heuristics_fallback), options);
|
|
Evaluation::from_matches(matched_rules)
|
|
}
|
|
|
|
/// Checks multiple commands and aggregates the results.
|
|
pub fn check_multiple<Commands, F>(
|
|
&self,
|
|
commands: Commands,
|
|
heuristics_fallback: &F,
|
|
) -> Evaluation
|
|
where
|
|
Commands: IntoIterator,
|
|
Commands::Item: AsRef<[String]>,
|
|
F: Fn(&[String]) -> Decision,
|
|
{
|
|
self.check_multiple_with_options(commands, heuristics_fallback, &MatchOptions::default())
|
|
}
|
|
|
|
pub fn check_multiple_with_options<Commands, F>(
|
|
&self,
|
|
commands: Commands,
|
|
heuristics_fallback: &F,
|
|
options: &MatchOptions,
|
|
) -> Evaluation
|
|
where
|
|
Commands: IntoIterator,
|
|
Commands::Item: AsRef<[String]>,
|
|
F: Fn(&[String]) -> Decision,
|
|
{
|
|
let matched_rules: Vec<RuleMatch> = commands
|
|
.into_iter()
|
|
.flat_map(|command| {
|
|
self.matches_for_command_with_options(
|
|
command.as_ref(),
|
|
Some(heuristics_fallback),
|
|
options,
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
Evaluation::from_matches(matched_rules)
|
|
}
|
|
|
|
/// Returns matching rules for the given command. If no rules match and
|
|
/// `heuristics_fallback` is provided, returns a single
|
|
/// `HeuristicsRuleMatch` with the decision rendered by
|
|
/// `heuristics_fallback`.
|
|
///
|
|
/// If `heuristics_fallback.is_some()`, then the returned vector is
|
|
/// guaranteed to be non-empty.
|
|
pub fn matches_for_command(
|
|
&self,
|
|
cmd: &[String],
|
|
heuristics_fallback: HeuristicsFallback<'_>,
|
|
) -> Vec<RuleMatch> {
|
|
self.matches_for_command_with_options(cmd, heuristics_fallback, &MatchOptions::default())
|
|
}
|
|
|
|
pub fn matches_for_command_with_options(
|
|
&self,
|
|
cmd: &[String],
|
|
heuristics_fallback: HeuristicsFallback<'_>,
|
|
options: &MatchOptions,
|
|
) -> Vec<RuleMatch> {
|
|
let matched_rules = self
|
|
.match_exact_rules(cmd)
|
|
.filter(|matched_rules| !matched_rules.is_empty())
|
|
.or_else(|| {
|
|
options
|
|
.resolve_host_executables
|
|
.then(|| self.match_host_executable_rules(cmd))
|
|
.filter(|matched_rules| !matched_rules.is_empty())
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
if matched_rules.is_empty()
|
|
&& let Some(heuristics_fallback) = heuristics_fallback
|
|
{
|
|
vec![RuleMatch::HeuristicsRuleMatch {
|
|
command: cmd.to_vec(),
|
|
decision: heuristics_fallback(cmd),
|
|
}]
|
|
} else {
|
|
matched_rules
|
|
}
|
|
}
|
|
|
|
fn match_exact_rules(&self, cmd: &[String]) -> Option<Vec<RuleMatch>> {
|
|
let first = cmd.first()?;
|
|
Some(
|
|
self.rules_by_program
|
|
.get_vec(first)
|
|
.map(|rules| rules.iter().filter_map(|rule| rule.matches(cmd)).collect())
|
|
.unwrap_or_default(),
|
|
)
|
|
}
|
|
|
|
fn match_host_executable_rules(&self, cmd: &[String]) -> Vec<RuleMatch> {
|
|
let Some(first) = cmd.first() else {
|
|
return Vec::new();
|
|
};
|
|
let Ok(program) = AbsolutePathBuf::try_from(first.clone()) else {
|
|
return Vec::new();
|
|
};
|
|
let Some((basename, rules)) = self.host_executable_rules(&program) else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let basename_command = std::iter::once(basename)
|
|
.chain(cmd.iter().skip(1).cloned())
|
|
.collect::<Vec<_>>();
|
|
rules
|
|
.iter()
|
|
.filter_map(|rule| rule.matches(&basename_command))
|
|
.map(|rule_match| rule_match.with_resolved_program(&program))
|
|
.collect()
|
|
}
|
|
|
|
fn host_executable_rules(&self, path: &AbsolutePathBuf) -> Option<(String, &[RuleRef])> {
|
|
let basename = executable_path_lookup_key(path.as_path())?;
|
|
if let Some(paths) = self.host_executables_by_name.get(&basename)
|
|
&& !paths.contains(path)
|
|
{
|
|
return None;
|
|
}
|
|
let rules = self.rules_by_program.get_vec(&basename)?;
|
|
Some((basename, rules))
|
|
}
|
|
}
|
|
|
|
fn can_weaken_fallback(exact: &RuleRef, fallback: &RuleRef, path_rules: &[RuleRef]) -> bool {
|
|
let (Some(exact), Some(fallback)) = (
|
|
exact.as_any().downcast_ref::<PrefixRule>(),
|
|
fallback.as_any().downcast_ref::<PrefixRule>(),
|
|
) else {
|
|
return true;
|
|
};
|
|
if exact.decision >= fallback.decision {
|
|
return false;
|
|
}
|
|
let (mut shorter, mut longer) = (&exact.pattern.rest, &fallback.pattern.rest);
|
|
if shorter.len() > longer.len() {
|
|
std::mem::swap(&mut shorter, &mut longer);
|
|
}
|
|
// The overlapping commands use arguments allowed by both prefixes where both apply.
|
|
let shared_arguments_at = |i: usize| {
|
|
longer[i].alternatives().iter().filter(move |arg| {
|
|
shorter
|
|
.get(i)
|
|
.is_none_or(|token| token.alternatives().contains(arg))
|
|
})
|
|
};
|
|
if (0..shorter.len()).any(|i| shared_arguments_at(i).next().is_none()) {
|
|
return false;
|
|
}
|
|
// Exact matches take the strongest decision. A shorter or equal exact rule
|
|
// can therefore preserve the fallback for every command in the overlap.
|
|
!path_rules.iter().any(|rule| {
|
|
let Some(rule) = rule.as_any().downcast_ref::<PrefixRule>() else {
|
|
return false;
|
|
};
|
|
rule.decision >= fallback.decision
|
|
&& rule.pattern.rest.len() <= longer.len()
|
|
&& rule.pattern.rest.iter().enumerate().all(|(i, token)| {
|
|
shared_arguments_at(i).all(|arg| token.alternatives().contains(arg))
|
|
})
|
|
})
|
|
}
|
|
|
|
fn upsert_domain(entries: &mut Vec<String>, host: &str) {
|
|
entries.retain(|entry| entry != host);
|
|
entries.push(host.to_string());
|
|
}
|
|
|
|
fn render_pattern_token(token: &PatternToken) -> String {
|
|
match token {
|
|
PatternToken::Single(value) => value.clone(),
|
|
PatternToken::Alts(alternatives) => format!("[{}]", alternatives.join("|")),
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Evaluation {
|
|
pub decision: Decision,
|
|
#[serde(rename = "matchedRules")]
|
|
pub matched_rules: Vec<RuleMatch>,
|
|
}
|
|
|
|
impl Evaluation {
|
|
pub fn is_match(&self) -> bool {
|
|
self.matched_rules
|
|
.iter()
|
|
.any(|rule_match| !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. }))
|
|
}
|
|
|
|
/// Caller is responsible for ensuring that `matched_rules` is non-empty.
|
|
fn from_matches(matched_rules: Vec<RuleMatch>) -> Self {
|
|
let decision = matched_rules.iter().map(RuleMatch::decision).max();
|
|
#[expect(clippy::expect_used)]
|
|
let decision = decision.expect("invariant failed: matched_rules must be non-empty");
|
|
|
|
Self {
|
|
decision,
|
|
matched_rules,
|
|
}
|
|
}
|
|
}
|