ExecPolicy语言与规则
ExecPolicy 不是 shell 字符串黑名单,也不是“找到第一条规则就停止”。规则文件用受限 Starlark builtin 构造 token-level prefix、network host 和 host executable 映射;运行时先把 shell 输入拆成多个命令段,再收集每一段的显式规则或 heuristic fallback,最终取最严格 decision。
安全性还取决于规则从哪里加载。项目仓库可以包含 .rules 文件,但只有进入可信配置层的规则才参与执行;requirements policy 作为高优先级 overlay 合并,即使用户规则解析失败也会保留。针对某些模型,Core 还会过滤 Allow prefix,同时保留 Prompt、Forbidden、network 与 executable 映射。
本文承接PermissionProfile解析和ApprovalPolicy完整参考。前文解释权限与提示;本文追踪 policy source、语言、匹配、聚合、Core 映射和 amendment。
1. 可信来源
Core 按配置层从低到高扫描每层的 rules/ 目录,只读取普通 .rules 文件。已被 trust decision 禁用的 project layer 不会出现在正常可信视图;requirements 还可以显式忽略 user/project rules,同时保留 system/managed 层。
源码位置:codex-rs/core/src/exec_policy.rs :: load_exec_policy
let mut policy_paths = Vec::new();
for layer in config_stack.layers_low_to_high() {
if config_stack.ignore_user_and_project_exec_policy_rules()
&& matches!(
layer.name,
ConfigLayerSource::User { .. } | ConfigLayerSource::Project { .. }
)
{
continue;
}
if let Some(config_folder) = layer.config_folder() {
let policy_dir = config_folder.join(RULES_DIR_NAME);
let layer_policy_paths = collect_policy_files(&policy_dir).await?;
policy_paths.extend(layer_policy_paths);
}
}文件按路径排序后依次解析。每层优先级不是通过“删除低层规则”实现,而是把所有规则放入 policy;同一 executable 的多条 prefix rule 可以并存,最终匹配时再取最严格结果。host executable 同名映射则由高层覆盖低层。
源码位置:codex-rs/core/src/exec_policy.rs :: load_exec_policy
let mut parser = PolicyParser::new();
for policy_path in &policy_paths {
let contents = fs::read_to_string(policy_path)
.await
.map_err(|source| ExecPolicyError::ReadFile {
path: policy_path.clone(),
source,
})?;
let identifier = policy_path.to_string_lossy().to_string();
parser
.parse(&identifier, &contents)
.map_err(|source| ExecPolicyError::ParsePolicy {
path: identifier,
source,
})?;
}
let policy = parser.build();
let Some(requirements_policy) = config_stack.requirements().exec_policy.as_deref() else {
return Ok(policy);
};
Ok(policy.merge_overlay(requirements_policy.as_ref()))若某个普通 policy 文件解析失败,load_exec_policy_with_warning 不会返回之前成功解析的半份用户 policy;它回退为空 policy 或 requirements policy,并返回 warning。managed requirements 因而不会被 malformed user rule 清空。
源码位置:codex-rs/core/src/exec_policy.rs :: load_exec_policy_with_warning
match load_exec_policy(config_stack).await {
Ok(policy) => Ok((policy, None)),
Err(err @ ExecPolicyError::ParsePolicy { .. }) => {
let policy = config_stack
.requirements()
.exec_policy
.as_deref()
.map_or_else(Policy::empty, |policy| policy.as_ref().clone());
Ok((policy, Some(err)))
}
Err(err) => Err(err),
}2. 规则语言
Starlark 环境主要提供 prefix_rule、network_rule 和 host_executable。prefix_rule 接受 pattern、decision、正反 examples 与 justification;decision 缺省为 Allow。
源码位置:codex-rs/execpolicy/src/parser.rs :: policy_builtins::prefix_rule
fn prefix_rule<'v>(
pattern: UnpackList<Value<'v>>,
decision: Option<&'v str>,
r#match: Option<UnpackList<Value<'v>>>,
not_match: Option<UnpackList<Value<'v>>>,
justification: Option<&'v str>,
eval: &mut Evaluator<'v, '_, '_>,
) -> anyhow::Result<NoneType> {
let decision = match decision {
Some(raw) => Decision::parse(raw)?,
None => Decision::Allow,
};
let justification = match justification {
Some(raw) if raw.trim().is_empty() => {
return Err(Error::InvalidRule("justification cannot be empty".to_string()).into());
}
Some(raw) => Some(raw.to_string()),
None => None,
};
let pattern_tokens = parse_pattern(pattern)?;
let matches: Vec<Vec<String>> =
r#match.map(parse_examples).transpose()?.unwrap_or_default();
let not_matches: Vec<Vec<String>> = not_match
.map(parse_examples)
.transpose()?
.unwrap_or_default();pattern 为空、decision 非法、justification 空白或 example 无法 token 化都会在加载期失败。example 不是文档装饰:parser.build 会验证声明为 match 的例子至少命中一条本组规则,not_match 则不能命中。
首 token 的 alternatives 会展开成多条规则,便于用 executable 名建立索引;尾部 alternatives 保留为一个 PatternToken::Alts,不会做笛卡尔积爆炸。
源码位置:codex-rs/execpolicy/src/parser.rs :: policy_builtins::prefix_rule
let (first_token, remaining_tokens) = pattern_tokens
.split_first()
.ok_or_else(|| Error::InvalidPattern("pattern cannot be empty".to_string()))?;
let rest: Arc<[PatternToken]> = remaining_tokens.to_vec().into();
let rules: Vec<RuleRef> = first_token
.alternatives()
.iter()
.map(|head| {
Arc::new(PrefixRule {
pattern: PrefixPattern {
first: Arc::from(head.as_str()),
rest: rest.clone(),
},
decision,
justification: justification.clone(),
}) as RuleRef
})
.collect();
builder.add_pending_example_validation(rules.clone(), matches, not_matches, location);
rules.into_iter().for_each(|rule| builder.add_rule(rule));3. Token匹配
规则匹配命令 token,不匹配未解析 shell 字符串。第一个 token 必须完全相等;后续位置可以是固定 token 或 alternatives;命令可以比 prefix 更长,但不能比它更短。
源码位置:codex-rs/execpolicy/src/rule.rs :: PatternToken, PrefixPattern::matches_prefix
pub enum PatternToken {
Single(String),
Alts(Vec<String>),
}
pub fn matches_prefix(&self, cmd: &[String]) -> Option<Vec<String>> {
let pattern_length = self.rest.len() + 1;
if cmd.len() < pattern_length || cmd[0] != self.first.as_ref() {
return None;
}
for (pattern_token, cmd_token) in self.rest.iter().zip(&cmd[1..pattern_length]) {
if !pattern_token.matches(cmd_token) {
return None;
}
}
Some(cmd[..pattern_length].to_vec())
}匹配成功返回实际 matched prefix,连同 decision、resolved program 与 justification 写入 RuleMatch::PrefixRuleMatch。Core 后续可以用 justification 生成 prompt/rejection reason,而不需要重新查规则文本。
4. Host可执行文件
命令首 token 可能是绝对路径,例如 /usr/bin/cargo。当 resolve_host_executables=true 时,Policy 可以用 basename 查找规则,但只有 path 在 host_executable(name, paths) 声明的 allowlist 中才接受该 basename rule。
源码位置:codex-rs/execpolicy/src/policy.rs :: Policy::matches_for_command_with_options
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
}exact path rule 优先于 basename resolution;存在显式空 allowlist 时,任何绝对 path 都不能借 basename rule 匹配。这样避免攻击者把同名恶意 executable 放到任意目录后复用可信规则。
5. Policy合并
Policy 内部包含 prefix multimap、network rules 与 host executable map。overlay 合并会追加 prefix/network rules,但同名 host executable paths 使用 overlay 覆盖。
源码位置:codex-rs/execpolicy/src/policy.rs :: Policy::merge_overlay
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,
)prefix 冲突不在 merge 阶段解决,因为严格度由实际匹配集合决定。network domain 编译则按规则顺序 upsert:Allow 会从 denied 删除同 host,Forbidden 会从 allowed 删除,Prompt 不进入静态 allow/deny 列表。
6. Shell命令分段
Core 先尝试解析 shell -lc 内部普通命令;Windows 还会尝试 PowerShell parser。解析成功且结果非空时,每个 segment 单独参与 policy;失败或空脚本则回退原始 argv,不能因 parser 不理解语法而跳过检查。
源码位置:codex-rs/core/src/exec_policy.rs :: commands_for_exec_policy
if let Some(commands) = parse_shell_lc_plain_commands(command)
&& !commands.is_empty()
{
return ExecPolicyCommands {
commands,
command_origin: ExecPolicyCommandOrigin::Generic,
};
}
#[cfg(windows)]
{
if let Some(commands) =
codex_shell_command::powershell::parse_powershell_command_into_plain_commands(command)
&& !commands.is_empty()
{
return ExecPolicyCommands {
commands,
command_origin: ExecPolicyCommandOrigin::PowerShell,
};
}
}
ExecPolicyCommands {
commands: vec![command.to_vec()],
command_origin: ExecPolicyCommandOrigin::Generic,
}heredoc、变量赋值、管道与连接符的处理由 shell parser 决定。复杂结构无法安全缩减成某个允许 prefix 时,Core 保留原始或完整命令 requirement,而不是只批准内部某个看似安全的 segment。
7. 多段聚合
check_multiple_with_options 收集所有 segment 的匹配结果,再交给 Evaluation::from_matches。Decision 的派生顺序是 Allow < Prompt < Forbidden,因此 max() 选择最严格结果。
源码位置:codex-rs/execpolicy/src/decision.rs :: Decision
pub enum Decision {
Allow,
Prompt,
Forbidden,
}源码位置:codex-rs/execpolicy/src/policy.rs :: Policy::check_multiple_with_options, Evaluation::from_matches
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)第一段只完成跨 segment 的 match 收集;最终 decision 在 Evaluation 构造时从完整集合计算。
源码位置:codex-rs/execpolicy/src/policy.rs :: Evaluation::from_matches
let decision = matched_rules.iter().map(RuleMatch::decision).max();
let decision = decision.expect("invariant failed: matched_rules must be non-empty");
Self {
decision,
matched_rules,
}Policy 不采用 first-match,也不让后加载 Allow 覆盖已有 Forbidden。overlay 可以增加限制,但无法通过追加一条宽泛 Allow 消除同一命令命中的更严格规则。
8. 模型Policy视图
ExecPolicyManager 可以为不同模型提供不同 policy 视图。IgnoreForCyberModel 只过滤 Decision::Allow 的 PrefixRule,保留 Prompt、Forbidden、network rules 和 host executable mappings;environment requirements overlay 在过滤之后继续应用。
源码位置:codex-rs/core/src/exec_policy/model_policy.rs :: ExecPolicyManager::current_for_prefix_rules
let policy = self.current();
if allow_prefix_rules == AllowPrefixRules::Honor {
return policy;
}
let rules = policy
.rules()
.iter_all()
.flat_map(|(program, rules)| {
rules.iter().filter_map(move |rule| {
let is_allow_prefix = rule
.as_any()
.downcast_ref::<PrefixRule>()
.is_some_and(|prefix| prefix.decision == Decision::Allow);
(!is_allow_prefix).then(|| (program.clone(), Arc::clone(rule)))
})
})
.collect();
Arc::new(Policy::from_parts(
rules,
policy.network_rules().to_vec(),
policy.host_executables().clone(),
))这不是把 policy 全部清空。模型仍然受到明确 Prompt/Forbidden 和 requirements 限制;只是不能利用宽泛 Allow prefix 自动获得可复用放行。
9. Core映射
Core 对所有 segments 调用 policy 后,把最终 Decision 转成 ExecApprovalRequirement。Allow 只有在每个 segment 都显式命中 Allow prefix 时才 bypass_sandbox=true;heuristic Allow 只能得到 Skip 但不绕过 sandbox。
源码位置:codex-rs/core/src/exec_policy.rs :: ExecPolicyManager::create_exec_approval_requirement_for_parsed_commands
let evaluation = exec_policy.check_multiple_with_options(
commands.iter(),
&exec_policy_fallback,
&match_options,
);
match evaluation.decision {
Decision::Forbidden => ExecApprovalRequirement::Forbidden {
reason: derive_forbidden_reason(
command,
&evaluation,
dangerous_command_match_for_heuristics(
&evaluation,
Decision::Forbidden,
command_origin,
),
),
},
Decision::Prompt => {
let prompt_is_rule = evaluation.matched_rules.iter().any(|rule_match| {
is_policy_match(rule_match) && rule_match.decision() == Decision::Prompt
});
match prompt_is_rejected_by_policy(approval_policy, prompt_is_rule) {
Some(reason) if prompt_is_rule => ExecApprovalRequirement::Forbidden {
reason: reason.to_string(),
},
Some(reason) => ExecApprovalRequirement::Forbidden {
reason: derive_rejected_prompt_reason(
reason,
dangerous_command_match_for_heuristics(
&evaluation,
Decision::Prompt,
command_origin,
),
),
},
None => ExecApprovalRequirement::NeedsApproval {
reason: derive_prompt_reason(command, &evaluation),
proposed_execpolicy_amendment: requested_amendment.or_else(|| {
if auto_amendment_allowed {
try_derive_execpolicy_amendment_for_prompt_rules(
&evaluation.matched_rules,
)
} else {
None
}
}),
},
}
}
Decision::Allow => ExecApprovalRequirement::Skip {
bypass_sandbox: commands.iter().all(|command| {
exec_policy
.matches_for_command_with_options(
command,
/*heuristics_fallback*/ None,
&match_options,
)
.iter()
.any(|rule_match| {
is_policy_match(rule_match) && rule_match.decision() == Decision::Allow
})
}),
proposed_execpolicy_amendment: if auto_amendment_allowed {
try_derive_execpolicy_amendment_for_allow_rules(&evaluation.matched_rules)
} else {
None
},
},
}完整 Prompt 分支会区分 rule prompt 与 sandbox prompt 的不同拒绝理由,并在允许 auto amendment 时从显式请求或 heuristic prompt 派生 proposal。下一节展开这部分。
10. Amendment生成
amendment 不是“把整条命令永远允许”。来源有三类:用户显式 prefix_rule 请求、heuristic Prompt 的第一条命令、sandbox 失败后的 heuristic Allow。显式 prefix 必须非空、不在 banned suggestions 中、当前没有 policy rule 匹配,并且临时加入后能让所有解析 segment 都变成 Allow。
源码位置:codex-rs/core/src/exec_policy.rs :: derive_requested_execpolicy_amendment_from_prefix_rule
let prefix_rule = prefix_rule?;
if prefix_rule.is_empty() {
return None;
}
if BANNED_PREFIX_SUGGESTIONS.iter().any(|banned| {
prefix_rule.len() == banned.len()
&& prefix_rule
.iter()
.map(String::as_str)
.eq(banned.iter().copied())
}) {
return None;
}
if matched_rules.iter().any(is_policy_match) {
return None;
}
let amendment = ExecPolicyAmendment::new(prefix_rule.clone());
if prefix_rule_would_approve_all_commands(
exec_policy,
&amendment.command,
commands,
exec_policy_fallback,
match_options,
) {
Some(amendment)
} else {
None
}cyber model 忽略 Allow prefix 时,auto_amendment_allowed=false,防止提示用户创建一个该模型后续也不会信任的可复用 Allow rule。
11. Amendment持久化
用户选择 exec-policy amendment 后,Core 在 blocking 线程中以 advisory lock 追加一行 prefix_rule(..., decision="allow"),然后更新内存 policy。更新锁串行化多个写入;空 prefix、目录创建、锁、读写与序列化错误都有独立类型。
源码位置:codex-rs/execpolicy/src/amend.rs :: blocking_append_allow_prefix_rule
if prefix.is_empty() {
return Err(AmendError::EmptyPrefix);
}
let tokens = prefix
.iter()
.map(serde_json::to_string)
.collect::<Result<Vec<_>, _>>()
.map_err(|source| AmendError::SerializePrefix { source })?;
let pattern = format!("[{}]", tokens.join(", "));
let rule = format!(r#"prefix_rule(pattern={pattern}, decision="allow")"#);
append_rule_line(policy_path, &rule)源码位置:codex-rs/core/src/exec_policy.rs :: ExecPolicyManager::append_amendment_and_update
let _update_guard = self
.update_lock
.acquire()
.await
.map_err(|_| ExecPolicyUpdateError::AddRule {
source: ExecPolicyRuleError::InvalidRule(
"exec policy update semaphore closed".to_string(),
),
})?;
let policy_path = default_policy_path(codex_home);
spawn_blocking({
let policy_path = policy_path.clone();
let prefix = amendment.command.clone();
move || blocking_append_allow_prefix_rule(&policy_path, &prefix)
})
.await
.map_err(|source| ExecPolicyUpdateError::JoinBlockingTask { source })?
.map_err(|source| ExecPolicyUpdateError::AppendRule {
path: policy_path,
source,
})?;文件写入成功后,manager 先检查当前内存 policy 是否已经有显式 Allow;没有时才 clone policy、add rule 并原子替换。磁盘和内存都更新后,下一次命令才能稳定复用该规则。
12. 测试反推边界
ExecPolicy crate 测试覆盖规则语言与聚合,Core 测试覆盖可信层、shell segment、requirements overlay、模型视图和 amendment:
cd codex-rs
cargo test -p codex-execpolicy --test basic -- --nocapture --test-threads=1
cargo test -p codex-core --lib exec_policy::tests:: -- --nocapture --test-threads=1
cargo test -p codex-core --lib exec_policy::model_policy::tests:: -- --nocapture --test-threads=1三组测试分别通过 27、83、4 项,共 114 项。它们覆盖语言、可信层加载、命令分段、聚合、模型过滤和 amendment;不证明所有 shell 方言都能被 parser 无损拆分。
strictest_decision_wins_across_matches 为同一 command 配置 Prompt 与 Forbidden,断言最终 Forbidden 且两条 match 都保留;strictest_decision_across_multiple_commands 对多个 segment 做同样验证。它们证明 max aggregation,不验证 shell parser 是否拆出了期望 segments。
exec_policies_only_load_from_trusted_project_layers 与无 config trust 配套测试把相同 .rules 放入 trusted/untrusted project,断言只有可信层生效。malformed_custom_rules_preserve_requirements_exec_policy 则让 user rule 解析失败,断言 managed restriction 仍保留。
cyber_policy_filters_allow_prefixes_but_preserves_restrictive_and_network_rules 输入 broad Allow、Prompt、Forbidden、network 与 host executable,断言 cyber view 只删除 Allow prefix。它限定模型 policy 过滤范围,不代表所有模型都使用该模式。
append_execpolicy_amendment_updates_policy_and_file 断言文件追加和内存 manager 同时更新;配套空 prefix 测试断言写入前失败。它验证持久化事务顺序,不验证外部编辑器与 Codex 并发修改时的业务冲突解决。
测试覆盖解析、匹配、聚合、加载和持久化,不能证明任意 shell 语法都可无损拆分。解析失败时 Core 回退原始 argv,是保持检查而非跳过检查的安全边界。
排查时先区分四类问题:规则没有加载看 config trust;规则加载但不命中看 token、absolute executable mapping;单段允许但整体 Prompt/Forbidden 看其他 segment;批准后未来仍询问看 amendment 是否被生成、持久化,以及当前模型是否忽略 Allow prefix。
下一篇Shell命令解析与安全分类将继续深入 shell parser、危险命令识别、heredoc、PowerShell 和 fallback 如何影响 ExecPolicy 输入。
