permissions: derive snapshot sandbox projections (#19775)

## Why

`ThreadConfigSnapshot` is used by app-server and thread metadata code as
a stable view of active runtime settings. Keeping both `sandbox_policy`
and `permission_profile` in the snapshot duplicates permission state and
makes it possible for the legacy projection to drift from the canonical
profile.

The legacy `sandbox` value is still needed at app-server compatibility
boundaries, so this PR derives it on demand from the snapshot profile
and cwd instead of storing it.

## What Changed

- Removes `ThreadConfigSnapshot.sandbox_policy`.
- Adds `ThreadConfigSnapshot::sandbox_policy()` as a compatibility
projection from `permission_profile` plus `cwd`.
- Updates app-server response/metadata code and tests to call the
projection only where legacy fields still exist.
- Keeps snapshot construction profile-only so split filesystem rules,
disabled enforcement, and external enforcement remain represented by the
canonical profile.

## Verification

- `cargo test -p codex-app-server
thread_response_permission_profile_preserves_enforcement --lib`
- `cargo test -p codex-core
dispatch_reclaims_stale_global_lock_and_starts_consolidation --lib`



































---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/19775).
* #19900
* #19899
* #19776
* __->__ #19775
This commit is contained in:
Michael Bolin
2026-04-27 22:30:47 -07:00
committed by GitHub
Unverified
parent bf38def44e
commit fc2a69107c
5 changed files with 26 additions and 14 deletions
@@ -2712,6 +2712,10 @@ impl CodexMessageProcessor {
/*has_in_progress_turn*/ false,
);
let sandbox = thread_response_sandbox_policy(
&config_snapshot.permission_profile,
config_snapshot.cwd.as_path(),
);
let permission_profile =
thread_response_permission_profile(config_snapshot.permission_profile);
@@ -2724,7 +2728,7 @@ impl CodexMessageProcessor {
instruction_sources,
approval_policy: config_snapshot.approval_policy.into(),
approvals_reviewer: config_snapshot.approvals_reviewer.into(),
sandbox: config_snapshot.sandbox_policy.into(),
sandbox,
permission_profile,
reasoning_effort: config_snapshot.reasoning_effort,
};
@@ -3284,7 +3288,7 @@ impl CodexMessageProcessor {
builder.model_provider = Some(model_provider.clone());
builder.cwd = config_snapshot.cwd.to_path_buf();
builder.cli_version = Some(env!("CARGO_PKG_VERSION").to_string());
builder.sandbox_policy = config_snapshot.sandbox_policy.clone();
builder.sandbox_policy = config_snapshot.sandbox_policy();
builder.approval_mode = config_snapshot.approval_policy;
let metadata = builder.build(model_provider.as_str());
if let Err(err) = state_db_ctx.insert_thread_if_absent(&metadata).await {
@@ -8099,7 +8103,6 @@ async fn handle_pending_thread_resume_request(
service_tier,
approval_policy,
approvals_reviewer,
sandbox_policy: _,
permission_profile,
cwd,
reasoning_effort,
@@ -8323,8 +8326,9 @@ fn collect_resume_override_mismatches(
}
}
if let Some(requested_sandbox) = request.sandbox.as_ref() {
let active_sandbox = config_snapshot.sandbox_policy();
let sandbox_matches = matches!(
(requested_sandbox, &config_snapshot.sandbox_policy),
(requested_sandbox, &active_sandbox),
(
SandboxMode::ReadOnly,
codex_protocol::protocol::SandboxPolicy::ReadOnly { .. }
@@ -8341,8 +8345,7 @@ fn collect_resume_override_mismatches(
);
if !sandbox_matches {
mismatch_details.push(format!(
"sandbox requested={requested_sandbox:?} active={:?}",
config_snapshot.sandbox_policy
"sandbox requested={requested_sandbox:?} active={active_sandbox:?}"
));
}
}
@@ -10042,7 +10045,6 @@ mod tests {
service_tier: Some(codex_protocol::config_types::ServiceTier::Flex),
approval_policy: codex_protocol::protocol::AskForApproval::OnRequest,
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User,
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
permission_profile: codex_protocol::models::PermissionProfile::Disabled,
cwd,
ephemeral: false,