命令规范化与审批缓存
Codex 对“这次执行可以继续”“本 Session 内相同请求不再询问”“以后匹配这个 prefix 的命令都允许”使用三种不同状态。Approved 只解决当前审批;ApprovedForSession 写入内存 ApprovalStore,复用条件由 environment、命令、cwd、TTY 和权限共同构成;ApprovedExecpolicyAmendment 则把 token prefix 写入 rules/default.rules,并同步更新当前进程的 ExecPolicy。
命令字符串不能直接作为缓存键。/bin/bash -lc 'cargo test' 与 bash -lc 'cargo test' 的 shell 文本不同,但 word-only argv 相同;heredoc 又不能只保留 python3,否则两个不同脚本会错误复用同一批准。Core 因此先生成 canonical command,再把它嵌入结构化 key。对于 environment 自带的 requirements ExecPolicy,key 还会加入排序后的 policy fingerprint,防止 policy 改变后继续复用旧批准。
本文承接Shell命令解析与安全分类和ExecPolicy语言与规则。前文解释 shell 如何拆段,本文关注批准如何复用以及复用何时失效;不把网络和 MCP 的独立持久化策略混入通用 command cache,也不把一次 Session approval 写成永久规则。
1. 三类复用
协议把可持久化 prefix 表示为透明数组类型 ExecPolicyAmendment。它不是 shell 字符串,也不包含 cwd、environment 或 TTY;command 中的每个 String 都是 prefix 的一个 token,最终会生成 prefix_rule(..., decision="allow")。
源码位置:codex-rs/protocol/src/approvals.rs :: ExecPolicyAmendment
/// Proposed execpolicy change to allow commands starting with this prefix.
///
/// The `command` tokens form the prefix that would be added as an execpolicy
/// `prefix_rule(..., decision="allow")`, letting the agent bypass approval for
/// commands that start with this token sequence.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(transparent)]
#[ts(type = "Array<string>")]
pub struct ExecPolicyAmendment {
pub command: Vec<String>,
}
impl ExecPolicyAmendment {
pub fn new(command: Vec<String>) -> Self {
Self { command }
}
pub fn command(&self) -> &[String] {
&self.command
}
}三类决定在 ReviewDecision 中也是三个独立变体。ApprovedForSession 的注释明确限定为 session-scoped approval cache;ApprovedExecpolicyAmendment 则要求携带服务端之前提出的 amendment。
源码位置:codex-rs/protocol/src/protocol.rs :: ReviewDecision
pub enum ReviewDecision {
/// User has approved this command and the agent should execute it.
Approved,
/// User has approved this command and wants to apply the proposed execpolicy
/// amendment so future matching commands are permitted.
ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: ExecPolicyAmendment,
},
/// User has approved this request and wants future prompts in the same
/// session-scoped approval cache to be automatically approved for the
/// remainder of the session.
ApprovedForSession,
/// User has approved this MCP tool call and wants to amend its policy so
/// matching future calls are automatically approved across sessions.
ApprovedMcpPolicyAmendment,
NetworkPolicyAmendment {
network_policy_amendment: NetworkPolicyAmendment,
},
Denied { rejection: String },
TimedOut,
Abort,
}这三者的复用边界如下:
| 决定 | 存储位置 | 作用域 | 匹配单位 |
|---|---|---|---|
Approved | 不保存 | 当前请求 | 当前 approval ID |
ApprovedForSession | SessionServices.tool_approvals | 当前 Session | 结构化 serialized key |
ApprovedExecpolicyAmendment | rules/default.rules 与内存 Policy | 后续 Session/重启 | token prefix rule |
2. 命令规范化
2.1 单命令展开
canonicalize_command_for_approval 首先复用 shell-command 的 plain parser。只有 lowering 后恰好得到一个 command,才直接使用 inner argv。/bin/bash -lc 'cargo test -p codex-core' 因而得到 ['cargo', 'test', '-p', 'codex-core'];由 shell wrapper 引入的空格差异不会进入 canonical command。
源码位置:codex-rs/core/src/command_canonicalization.rs :: canonicalize_command_for_approval
const CANONICAL_BASH_SCRIPT_PREFIX: &str = "__codex_shell_script__";
const CANONICAL_POWERSHELL_SCRIPT_PREFIX: &str = "__codex_powershell_script__";
/// Canonicalize command argv for approval-cache matching.
///
/// This keeps approval decisions stable across wrapper-path differences (for
/// example `/bin/bash -lc` vs `bash -lc`) and across shell wrapper tools while
/// preserving exact script text for complex scripts where we cannot safely
/// recover a tokenized command sequence.
pub(crate) fn canonicalize_command_for_approval(command: &[String]) -> Vec<String> {
if let Some(commands) = parse_shell_lc_plain_commands(command)
&& let [single_command] = commands.as_slice()
{
return single_command.clone();
}
if let Some((_shell, script)) = extract_bash_command(command) {
let shell_mode = command.get(1).cloned().unwrap_or_default();
return vec![
CANONICAL_BASH_SCRIPT_PREFIX.to_string(),
shell_mode,
script.to_string(),
];
}
if let Some((_shell, script)) = extract_powershell_command(command) {
return vec![
CANONICAL_POWERSHELL_SCRIPT_PREFIX.to_string(),
script.to_string(),
];
}
command.to_vec()
}注意 [single_command] 的切片模式:即使 cargo build && echo ok 能安全拆成两个 plain segment,也不会把两个命令拼成一个 canonical argv。它会进入 Bash script sentinel 分支,保留完整脚本文本。
源码位置:codex-rs/core/src/command_canonicalization_tests.rs :: canonicalizes_word_only_shell_scripts_to_inner_command
#[test]
fn canonicalizes_word_only_shell_scripts_to_inner_command() {
let command_a = vec![
"/bin/bash".to_string(),
"-lc".to_string(),
"cargo test -p codex-core".to_string(),
];
let command_b = vec![
"bash".to_string(),
"-lc".to_string(),
"cargo test -p codex-core".to_string(),
];
assert_eq!(
canonicalize_command_for_approval(&command_a),
vec![
"cargo".to_string(),
"test".to_string(),
"-p".to_string(),
"codex-core".to_string(),
]
);
assert_eq!(
canonicalize_command_for_approval(&command_a),
canonicalize_command_for_approval(&command_b)
);
}2.2 复杂脚本键
heredoc、重定向、变量展开或多个 segment 不能缩成一个 argv,因此 canonical form 使用 __codex_shell_script__、原始 -c/-lc mode 和完整 script。这样 /bin/zsh 与 zsh 的 wrapper path 可以共享 command component,但脚本文本或 shell mode 的任何变化仍会产生不同结果。
源码位置:codex-rs/core/src/command_canonicalization_tests.rs :: canonicalizes_heredoc_scripts_to_stable_script_key
#[test]
fn canonicalizes_heredoc_scripts_to_stable_script_key() {
let script = "python3 <<'PY'\nprint('hello')\nPY";
let command_a = vec![
"/bin/zsh".to_string(),
"-lc".to_string(),
script.to_string(),
];
let command_b = vec!["zsh".to_string(), "-lc".to_string(), script.to_string()];
assert_eq!(
canonicalize_command_for_approval(&command_a),
vec![
"__codex_shell_script__".to_string(),
"-lc".to_string(),
script.to_string(),
]
);
assert_eq!(
canonicalize_command_for_approval(&command_a),
canonicalize_command_for_approval(&command_b)
);
}这不是把复杂脚本判为等价,而是把 wrapper executable 从 command component 中移除,同时保留真正影响执行的 mode 和完整 source text。
2.3 PowerShell键
PowerShell 使用独立 sentinel,并保留完整 -Command body。powershell.exe -NoProfile -Command 'Write-Host hi' 与 powershell -Command 'Write-Host hi' 的 command component 相同,但不同脚本不会合并。
源码位置:codex-rs/core/src/command_canonicalization_tests.rs :: canonicalizes_powershell_wrappers_to_stable_script_key
#[test]
fn canonicalizes_powershell_wrappers_to_stable_script_key() {
let script = "Write-Host hi";
let command_a = vec![
"powershell.exe".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
script.to_string(),
];
let command_b = vec![
"powershell".to_string(),
"-Command".to_string(),
script.to_string(),
];
assert_eq!(
canonicalize_command_for_approval(&command_a),
vec![
"__codex_powershell_script__".to_string(),
script.to_string(),
]
);
assert_eq!(
canonicalize_command_for_approval(&command_a),
canonicalize_command_for_approval(&command_b)
);
}非 shell command 不做 basename、path 或参数重排,直接复制原始 argv。因此普通 executable 的绝对路径和相对名称默认不是同一个 canonical command。
3. Session缓存键
3.1 结构字段
Unified Exec 的 approval key 不只有 canonical command。结构体还记录 environment ID、原始 executable、cwd、TTY、sandbox permissions 和 additional permissions。原始 executable 单独存在意味着:即使 canonical command component 去除了 /bin/bash 与 bash 的差异,完整 key 仍会按 executable 字段区分它们。
源码位置:codex-rs/core/src/tools/runtimes/unified_exec.rs :: UnifiedExecApprovalKey
/// Cache key for approval decisions that can be reused across equivalent
/// unified-exec launches.
#[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)]
pub struct UnifiedExecApprovalKey {
pub environment_id: String,
pub executable: Option<String>,
pub command: Vec<String>,
pub cwd: PathUri,
pub tty: bool,
pub sandbox_permissions: SandboxPermissions,
pub additional_permissions: Option<AdditionalPermissionProfile>,
}ApprovalAction::cache_keys 从真实执行请求构造 key。Execve interception、MCP、network 和 request_permissions 返回空 key,不进入这套通用缓存;Apply Patch 则按每个目标文件分别产生 key。
源码位置:codex-rs/core/src/tools/approvals.rs :: ApprovalAction::cache_keys
pub(crate) fn cache_keys(&self) -> Vec<ApprovalCacheKey> {
match self {
Self::ExecCommand {
environment_id,
command,
cwd,
tty,
sandbox_permissions,
additional_permissions,
..
} => vec![ApprovalCacheKey::ExecCommand(UnifiedExecApprovalKey {
environment_id: environment_id.clone(),
executable: command.first().cloned(),
command: canonicalize_command_for_approval(command),
cwd: cwd.clone(),
tty: *tty,
sandbox_permissions: *sandbox_permissions,
additional_permissions: additional_permissions.clone(),
})],
#[cfg(unix)]
Self::Execve { .. } => Vec::new(),
Self::McpToolCall { .. }
| Self::NetworkAccess { .. }
| Self::RequestPermissions { .. } => Vec::new(),
Self::ApplyPatch {
environment_id,
files,
..
} => files
.iter()
.cloned()
.map(|path| {
ApprovalCacheKey::ApplyPatch(ApplyPatchApprovalKey {
environment_id: environment_id.clone(),
path,
})
})
.collect(),
}
}这组字段建立了复用不变量:切换 remote environment、工作目录、TTY 模式、sandbox override 或 additional permissions 都不能命中旧 key。key 没有把“看起来相似”当成等价,而是要求执行上下文的安全相关字段相同。
3.2 Environment隔离
Unified Exec runtime 从 TurnEnvironment.selection.environment_id 建立 approval action。测试只改变 environment ID,其余请求保持不变,断言两个 key 不相等。
源码位置:codex-rs/core/src/tools/runtimes/unified_exec.rs :: approval_key_includes_environment_id
#[tokio::test]
async fn approval_key_includes_environment_id() {
let manager = UnifiedExecProcessManager::default();
let runtime = UnifiedExecRuntime::new(&manager, UnifiedExecShellMode::Direct);
let mut request = test_request(
SandboxPermissions::UseDefault,
ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: None,
},
);
request.turn_environment.selection.environment_id = "remote".to_string();
let original_key = runtime
.approval_action(&request, "call-1")
.expect("build approval action")
.cache_keys();
request.turn_environment.selection.environment_id = "other".to_string();
let other_key = runtime
.approval_action(&request, "call-1")
.expect("build approval action")
.cache_keys();
assert_ne!(original_key, other_key);
}3.3 Policy指纹
environment 可能在 Session 期间提供新的 requirements ExecPolicy。Core 把该 policy 的 fingerprint 与 ApprovalCacheKey 组成 tuple,再交给通用 cache。fingerprint 由所有 prefix rules 的 debug representation 排序生成,因此 rule 顺序不同但内容相同会得到同样指纹,内容变化则使旧 approval miss。
源码位置:codex-rs/execpolicy/src/policy.rs :: RequirementsExecPolicy::fingerprint
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
}
}源码位置:codex-rs/core/src/tools/approvals.rs :: request_user_approval ExecCommand branch
let policy_fingerprint = ctx
.review_context
.environments()
.turn_environments()
.find(|environment| environment.selection.environment_id == *environment_id)
.and_then(|environment| environment.config().exec_policy.as_ref())
.map(codex_execpolicy::RequirementsExecPolicy::fingerprint);
let cache_keys = action
.cache_keys()
.into_iter()
.map(|key| (key, &policy_fingerprint))
.collect();
with_cached_approval(&self.services, tool_name, cache_keys, || async {
self.request_command_approval(
ctx.review_context.turn(),
ctx.call_id.clone(),
/*approval_id*/ None,
Some(environment_id.clone()),
command.clone(),
cwd,
reason,
ctx.network_approval_context.clone(),
proposed_execpolicy_amendment.clone(),
additional_permissions.clone(),
/*available_decisions*/ None,
/*plugin_attribution_override*/ None,
)
.await
})
.await这里的指纹针对 environment-owned ExecPolicy,不是完整 Session 配置 hash。其它 key 字段仍由 UnifiedExecApprovalKey 单独承担。
4. Session缓存流程
4.1 命中条件
ApprovalStore 把任意可序列化 key 转为 JSON string,再存储 ReviewDecision。with_cached_approval 只有在 key 非空且每一个 key 都已经保存为 ApprovedForSession 时才跳过 fetch;普通 Approved、Denied 或部分 key 命中都不会复用。
源码位置:codex-rs/core/src/tools/sandboxing.rs :: ApprovalStore, with_cached_approval
#[derive(Clone, Default, Debug)]
pub(crate) struct ApprovalStore {
// Store serialized keys for generic caching across requests.
map: HashMap<String, ReviewDecision>,
}
impl ApprovalStore {
pub fn get<K>(&self, key: &K) -> Option<ReviewDecision>
where
K: Serialize,
{
let s = serde_json::to_string(key).ok()?;
self.map.get(&s).cloned()
}
pub fn put<K>(&mut self, key: K, value: ReviewDecision)
where
K: Serialize,
{
if let Ok(s) = serde_json::to_string(&key) {
self.map.insert(s, value);
}
}
}
pub(crate) async fn with_cached_approval<K, F, Fut>(
services: &SessionServices,
tool_name: &str,
keys: Vec<K>,
fetch: F,
) -> ReviewDecision
where
K: Serialize,
F: FnOnce() -> Fut,
Fut: Future<Output = ReviewDecision>,
{
if keys.is_empty() {
return fetch().await;
}
let already_approved = {
let store = services.tool_approvals.lock().await;
keys.iter()
.all(|key| matches!(store.get(key), Some(ReviewDecision::ApprovedForSession)))
};
if already_approved {
return ReviewDecision::ApprovedForSession;
}fetch 完成后,只有 ApprovedForSession 会逐 key 写回。telemetry 在 fetch 之后记录真实决定;cache hit 直接返回,不再产生新的用户审批请求。
源码位置:codex-rs/core/src/tools/sandboxing.rs :: with_cached_approval
let decision = fetch().await;
services.session_telemetry.counter(
"codex.approval.requested",
/*inc*/ 1,
&[
("tool", tool_name),
("approved", decision.to_opaque_string()),
],
);
if matches!(decision, ReviewDecision::ApprovedForSession) {
let mut store = services.tool_approvals.lock().await;
for key in keys {
store.put(key, ReviewDecision::ApprovedForSession);
}
}
decision4.2 Session所有权
缓存挂在 SessionServices.tool_approvals,由 tokio::sync::Mutex 保护。它跨 Turn 存活,但不会自然跨 Session 重建;新的 SessionServices 会创建新的 ApprovalStore。
源码位置:codex-rs/core/src/state/service.rs :: SessionServices
pub(crate) struct SessionServices {
/// The single owner of live MCP connections for this thread.
pub(crate) mcp_runtime: Arc<McpRuntime>,
/// Immutable MCP handlers scoped to this thread's current binding.
pub(crate) mcp_handler_cache: McpHandlerCache,
pub(crate) unified_exec_manager: UnifiedExecProcessManager,
pub(crate) elicitations: ElicitationService,
#[cfg_attr(not(unix), allow(dead_code))]
pub(crate) shell_zsh_path: Option<PathBuf>,
#[cfg_attr(not(unix), allow(dead_code))]
pub(crate) main_execve_wrapper_exe: Option<PathBuf>,
pub(crate) analytics_events_client: AnalyticsEventsClient,
pub(crate) hooks: ArcSwap<Hooks>,
pub(crate) rollout_thread_trace: ThreadTraceContext,
pub(crate) user_shell: Arc<crate::shell::Shell>,
pub(crate) show_raw_agent_reasoning: bool,
pub(crate) exec_policy: Arc<ExecPolicyManager>,
pub(crate) auth_manager: Arc<AuthManager>,
/// Upload-only clients shared across turns without logging signed blob URLs.
pub(crate) openai_file_upload_client_pool: RouteAwareClientPool,
pub(crate) models_manager: SharedModelsManager,
pub(crate) session_telemetry: SessionTelemetry,
pub(crate) tool_approvals: Mutex<ApprovalStore>,
pub(crate) guardian_rejection_circuit_breaker: Mutex<GuardianRejectionCircuitBreaker>,
pub(crate) runtime_handle: Handle,
pub(crate) skills_service: Arc<HostSkillsService>,Apply Patch 的多文件语义展示了 all 与逐 key 写入的目的:第一次批准 A、B 两个文件时分别存储;后续只修改 A,可以命中 A 的 key。反过来,若新请求包含未批准的 C,all 为 false,仍会请求审批。
5. Amendment候选
5.1 两种来源
ExecPolicy evaluation 可以从显式 requested prefix_rule 或 heuristic match 生成 amendment。Prompt 场景若已有显式 Prompt rule,不能再建议一个 Allow amendment;否则取第一个 heuristic Prompt。Allow 场景只在没有任何显式 policy match 时取 heuristic Allow。
源码位置:codex-rs/core/src/exec_policy.rs :: try_derive_execpolicy_amendment_for_prompt_rules, try_derive_execpolicy_amendment_for_allow_rules
fn try_derive_execpolicy_amendment_for_prompt_rules(
matched_rules: &[RuleMatch],
) -> Option<ExecPolicyAmendment> {
if matched_rules
.iter()
.any(|rule_match| is_policy_match(rule_match) && rule_match.decision() == Decision::Prompt)
{
return None;
}
matched_rules
.iter()
.find_map(|rule_match| match rule_match {
RuleMatch::HeuristicsRuleMatch {
command,
decision: Decision::Prompt,
} => Some(ExecPolicyAmendment::from(command.clone())),
_ => None,
})
}
fn try_derive_execpolicy_amendment_for_allow_rules(
matched_rules: &[RuleMatch],
) -> Option<ExecPolicyAmendment> {
if matched_rules.iter().any(is_policy_match) {
return None;
}
matched_rules
.iter()
.find_map(|rule_match| match rule_match {
RuleMatch::HeuristicsRuleMatch {
command,
decision: Decision::Allow,
} => Some(ExecPolicyAmendment::from(command.clone())),
_ => None,
})
}当前实现不再用 used_complex_parsing 全局关闭 amendment。是否允许自动生成候选只取决于 AllowPrefixRules::Honor;复杂 shell 在无法采用请求 prefix 时,可以回退为完整 wrapper command amendment。
源码位置:codex-rs/core/src/exec_policy.rs :: create_exec_approval_requirement_for_parsed_commands
let exec_policy = self.current_for_environment(environment_policy, allow_prefix_rules);
// Avoid reusable approvals when this model does not honor prefix rules.
let auto_amendment_allowed = allow_prefix_rules == AllowPrefixRules::Honor;
let exec_policy_fallback = |cmd: &[String]| {
render_decision_for_unmatched_command(
cmd,
UnmatchedCommandContext {
approval_policy,
permission_profile: &permission_profile,
windows_sandbox_level,
sandbox_permissions,
command_origin,
},
)
};5.2 Prefix拒绝
显式 requested prefix 必须非空、不能与 BANNED_PREFIX_SUGGESTIONS 完全相等,并且当前 evaluation 不能已有任何显式 policy match。banlist 覆盖 shell wrapper、python -c、node -e、PowerShell、裸 git、裸 rm 和 sudo 等过宽边界。
源码位置: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
}banlist 使用精确 token equality,而不是 starts-with。['python', '-c'] 被拒绝,['python', '-c', "print('hi')"] 可以继续进入全段模拟。后者仍然是具体脚本文本 prefix,不会授权所有 python -c。
5.3 全段模拟
候选 prefix 会被临时加入 policy clone,然后对 shell parser 给出的每个 command segment 重跑 check_with_options。任一段得到 Prompt 或 Forbidden,就不能把该 prefix 作为“批准整次调用”的 amendment。
源码位置:codex-rs/core/src/exec_policy.rs :: prefix_rule_would_approve_all_commands
fn prefix_rule_would_approve_all_commands(
exec_policy: &Policy,
prefix_rule: &[String],
commands: &[Vec<String>],
exec_policy_fallback: &impl Fn(&[String]) -> Decision,
match_options: &MatchOptions,
) -> bool {
let mut policy_with_prefix_rule = exec_policy.clone();
if policy_with_prefix_rule
.add_prefix_rule(prefix_rule, Decision::Allow)
.is_err()
{
return false;
}
commands.iter().all(|command| {
policy_with_prefix_rule
.check_with_options(command, exec_policy_fallback, match_options)
.decision
== Decision::Allow
})
}测试输入 cargo install cargo-insta && rm -rf /tmp/codex 请求 ['cargo', 'install']。临时 rule 只能批准第一段,第二段仍是 Prompt,因此 requested prefix 被拒绝,最终候选回退到 heuristic Prompt 的 ['rm', '-rf', '/tmp/codex']。
源码位置:codex-rs/core/src/exec_policy_tests.rs :: request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands
let command = vec![
"bash".to_string(),
"-lc".to_string(),
"cargo install cargo-insta && rm -rf /tmp/codex".to_string(),
];
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::Disabled,
environment_policy: None,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]),
allow_prefix_rules: AllowPrefixRules::Honor,
})
.await;
assert_eq!(
requirement,
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
"rm".to_string(),
"-rf".to_string(),
"/tmp/codex".to_string(),
])),
}
);6. 用户决定
普通 command approval 的默认 decisions 是 Approved、可选的 ApprovedExecpolicyAmendment 和 Abort。只有服务端确实提出 amendment 时,客户端才会看到持久化选项。网络 prompt 的默认 choices 才包含 ApprovedForSession;additional permissions 则只允许一次批准或终止。
源码位置:codex-rs/protocol/src/approvals.rs :: ExecApprovalRequestEvent::default_available_decisions
if network_approval_context.is_some() {
let mut decisions = vec![ReviewDecision::Approved, ReviewDecision::ApprovedForSession];
if let Some(amendment) = proposed_network_policy_amendments.and_then(|amendments| {
amendments
.iter()
.find(|amendment| amendment.action == NetworkPolicyRuleAction::Allow)
}) {
decisions.push(ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment: amendment.clone(),
});
}
decisions.push(ReviewDecision::Abort);
return decisions;
}
if additional_permissions.is_some() {
return vec![ReviewDecision::Approved, ReviewDecision::Abort];
}
let mut decisions = vec![ReviewDecision::Approved];
if let Some(prefix) = proposed_execpolicy_amendment {
decisions.push(ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: prefix.clone(),
});
}
decisions.push(ReviewDecision::Abort);
decisions这意味着协议支持 ApprovedForSession,通用 cache 也能处理它,但普通 command prompt 的默认 UI choices 不自动展示该选项。客户端若使用服务端提供的 available_decisions,不会把 Session cache 与 ExecPolicy persistence 混成同一个按钮。
7. 规则落盘
7.1 Token序列化
持久化入口拒绝空 prefix。每个 token 通过 serde_json::to_string 转义,再拼成 Starlark list;空格、引号和反斜杠因此按 token 数据保存,而不是按 shell source 手工插值。
源码位置:codex-rs/execpolicy/src/amend.rs :: blocking_append_allow_prefix_rule
pub fn blocking_append_allow_prefix_rule(
policy_path: &Path,
prefix: &[String],
) -> Result<(), AmendError> {
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)
}7.2 文件锁与去重
append_rule_line 创建 rules/ 目录后,以 read + append 模式打开文件并获取 advisory lock。它从头读取现有内容;若已经存在完全相同的一行则直接成功。文件末尾没有换行时先补一个换行,再追加新 rule。
源码位置:codex-rs/execpolicy/src/amend.rs :: append_rule_line, append_locked_line
fn append_rule_line(policy_path: &Path, rule: &str) -> Result<(), AmendError> {
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 mut file = OpenOptions::new()
.create(true)
.read(true)
.append(true)
.open(policy_path)
.map_err(|source| AmendError::OpenPolicyFile {
path: policy_path.to_path_buf(),
source,
})?;
file.lock().map_err(|source| AmendError::LockPolicyFile {
path: policy_path.to_path_buf(),
source,
})?;
file.seek(SeekFrom::Start(0))
.map_err(|source| AmendError::SeekPolicyFile {
path: policy_path.to_path_buf(),
source,
})?;
let mut contents = String::new();
file.read_to_string(&mut contents)
.map_err(|source| AmendError::ReadPolicyFile {
path: policy_path.to_path_buf(),
source,
})?;
if contents.lines().any(|existing| existing == line) {
return Ok(());
}advisory lock 只能协调遵守同一 locking protocol 的写入者;外部进程若无锁改写文件,仍可能竞争。文件级 exact-line 去重也不等于语义去重:格式不同但语义相同的 rule 可以同时存在,运行时 strictest decision 会处理多个匹配。
8. 内存更新
Core 用 Semaphore 串行化 amendment update,把阻塞文件 IO 放进 spawn_blocking。写盘后,它检查当前内存 policy 是否已经有显式 Allow;若没有,clone Policy、加入 prefix rule,再用 ArcSwap::store 原子替换。
源码位置:codex-rs/core/src/exec_policy.rs :: ExecPolicyManager::append_amendment_and_update
pub(crate) async fn append_amendment_and_update(
&self,
codex_home: &Path,
amendment: &ExecPolicyAmendment,
) -> Result<(), ExecPolicyUpdateError> {
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,
})?;
let current_policy = self.current();
let match_options = MatchOptions {
resolve_host_executables: true,
};
let existing_evaluation = current_policy.check_multiple_with_options(
[&amendment.command],
&|_| Decision::Forbidden,
&match_options,
);源码位置:codex-rs/core/src/exec_policy.rs :: ExecPolicyManager::append_amendment_and_update
let already_allowed = existing_evaluation.decision == Decision::Allow
&& existing_evaluation.matched_rules.iter().any(|rule_match| {
is_policy_match(rule_match) && rule_match.decision() == Decision::Allow
});
if already_allowed {
return Ok(());
}
let mut updated_policy = current_policy.as_ref().clone();
updated_policy.add_prefix_rule(&amendment.command, Decision::Allow)?;
self.policy.store(Arc::new(updated_policy));
Ok(())“先写盘、后更新内存”的顺序意味着写盘失败不会污染当前 Policy;反过来,写盘成功而内存 add_prefix_rule 失败时,文件已包含 rule,但本进程需要后续 reload 才能看到。错误类型保留 AppendRule、JoinBlockingTask 和 AddRule 三个阶段。
9. 失败路径
用户决定由 session handler 接收。若是 ApprovedExecpolicyAmendment,handler 先尝试持久化;失败时发送 Warning,但随后仍把原决定交给 pending approval。也就是说,规则保存失败不会把“批准当前执行”改写成 Abort,只是未来复用没有建立。
源码位置:codex-rs/core/src/session/handlers.rs :: exec_approval
pub async fn exec_approval(
sess: &Arc<Session>,
approval_id: String,
turn_id: Option<String>,
decision: ReviewDecision,
) {
let event_turn_id = turn_id.unwrap_or_else(|| approval_id.clone());
if let ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment,
} = &decision
&& let Err(err) = sess
.persist_execpolicy_amendment(proposed_execpolicy_amendment)
.await
{
let message = format!("Failed to apply execpolicy amendment: {err}");
tracing::warn!("{message}");
let warning = EventMsg::Warning(WarningEvent { message });
sess.send_event_raw(Event {
id: event_turn_id.clone(),
msg: warning,
})
.await;
}
match decision {
ReviewDecision::Abort => {
sess.interrupt_task().await;
}
other => sess.notify_approval(&approval_id, other).await,
}
}Session cache 失败路径不同:key serialization 失败时 ApprovalStore::get 返回 None、put 静默跳过,因此当前 fetch 仍可继续,但不会复用。空 key 的 action 也直接执行 fetch,避免“没有身份的批准”进入全局桶。
10. 测试边界
几组测试共同定义了文章可以外推的范围:
- command canonicalization 的 4 项单测覆盖单个 word-only shell、heredoc sentinel、PowerShell sentinel 和非 shell 原样保留。
- amend 的 6 项单测覆盖目录创建、token 序列化、末尾换行、prefix/network 共存和非法 network host;prefix exact-line 去重由
append_locked_line源码路径保证。 - Core ExecPolicy 测试覆盖 banned prefix、请求 prefix 的全段模拟、heuristic fallback、文件与内存同时更新和空 prefix 错误。
environment_command_policy_changes_invalidate_session_approvals先返回ApprovedForSession,随后改变同一 environment 的 policy,断言第二次必须出现新的 command approval。- Apply Patch 集成测试先对同一文件返回
ApprovedForSession,下一 Turn 修改该文件时断言直接完成,展示ApprovalStore跨 Turn、逐 key 的 Session 语义。 - amendment 端到端测试先确认
default.rules已写入目标 prefix;当前 macOS fixture 随后的命令执行在 shelleval中出现 parse error,因此该测试没有完成“第二次执行无提示”的最终断言,不能计为完整通过。
源码位置:
codex-rs/core/src/command_canonicalization_tests.rs :: testscodex-rs/core/src/exec_policy_tests.rs :: amendment testscodex-rs/execpolicy/src/amend.rs :: testscodex-rs/core/tests/suite/exec_policy.rs :: environment_command_policy_changes_invalidate_session_approvalscodex-rs/core/tests/suite/approvals.rs :: approving_apply_patch_for_session_skips_future_prompts_for_same_file, approving_execpolicy_amendment_persists_policy_and_skips_future_prompts
下面的 integration 断言展示 policy fingerprint 的可观察结果:两次 command 相同,第一次批准为 Session,第二次只改变 environment ExecPolicy,仍必须收到新的 approval event。
源码位置:codex-rs/core/tests/suite/exec_policy.rs :: environment_command_policy_changes_invalidate_session_approvals
for (attempt, decision) in [
("before-owner-policy", ReviewDecision::ApprovedForSession),
("after-owner-policy", ReviewDecision::Approved),
] {
if attempt == "after-owner-policy" {
let mut policy = Policy::empty();
policy.add_prefix_rule(&["echo".to_string()], Decision::Prompt)?;
test.codex
.environment_ready(
&selection,
EnvironmentConfig {
allow_login_shell: true,
permission_profile: PermissionProfileSnapshot::legacy(
PermissionProfile::Disabled,
),
shell_environment_policy: Default::default(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&test.config),
windows_sandbox_private_desktop: test
.config
.permissions
.windows_sandbox_private_desktop,
use_legacy_landlock: test.config.features.use_legacy_landlock(),
exec_policy: Some(RequirementsExecPolicy::new(policy)),
mcp_policy: None,
network_policy: None,
selected_capability_roots: Vec::new(),
},
)
.await?;
}
let args = json!({ "cmd": "echo approval", "yield_time_ms": 1_000 });这些结果不能证明 canonicalization 等价于完整 shell 语义,也不能证明 advisory lock 能阻止不遵守锁协议的外部写入者。Session cache 只复用用户批准,不会跳过后续 sandbox enforcement;ExecPolicy amendment 则会改变未来 policy evaluation,是否能 bypass sandbox 仍取决于所有 command segment 都有显式 Allow。
11. 继续阅读
可以沿三条路径检查自己的理解:
- 从
ApprovalAction::cache_keys开始,说明同一脚本在 environment、cwd、TTY 或 additional permissions 变化后为何不能复用 Session approval。 - 从
derive_requested_execpolicy_amendment_from_prefix_rule开始,说明为什么cargo install && rm -rf不能只保存cargo install。 - 从
exec_approval走到append_amendment_and_update,说明写盘失败、内存更新失败和成功更新分别怎样影响当前执行与未来执行。
下一篇跨平台Sandbox抽象将继续分析 approval requirement 之后,PermissionProfile 如何选择本地或 executor-managed sandbox backend。
