Protect first-time project .codex creation across Linux and macOS sandboxes (#15067)

## Problem

Codex already treated an existing top-level project `./.codex` directory
as protected, but there was a gap on first creation.

If `./.codex` did not exist yet, a turn could create files under it,
such as `./.codex/config.toml`, without going through the same approval
path as later modifications. That meant the initial write could bypass
the intended protection for project-local Codex state.

## What this changes

This PR closes that first-creation gap in the Unix enforcement layers:

- `codex-protocol`
- treat the top-level project `./.codex` path as a protected carveout
even when it does not exist yet
- avoid injecting the default carveout when the user already has an
explicit rule for that exact path
- macOS Seatbelt
- deny writes to both the exact protected path and anything beneath it,
so creating `./.codex` itself is blocked in addition to writes inside it
- Linux bubblewrap
- preserve the same protected-path behavior for first-time creation
under `./.codex`
- tests
- add protocol regressions for missing `./.codex` and explicit-rule
collisions
- add Unix sandbox coverage for blocking first-time `./.codex` creation
  - tighten Seatbelt policy assertions around excluded subpaths

## Scope

This change is intentionally scoped to protecting the top-level project
`.codex` subtree from agent writes.

It does not make `.codex` unreadable, and it does not change the product
behavior around loading project skills from `.codex` when project config
is untrusted.

## Why this shape

The fix is pointed rather than broad:
- it preserves the current model of “project `.codex` is protected from
writes”
- it closes the security-relevant first-write hole
- it avoids folding a larger permissions-model redesign into this PR

## Validation

- `cargo test -p codex-protocol`
- `cargo test -p codex-sandboxing seatbelt`
- `cargo test -p codex-exec --test all
sandbox_blocks_first_time_dot_codex_creation -- --nocapture`

---------

Co-authored-by: Michael Bolin <mbolin@openai.com>
This commit is contained in:
rreichel3-oai
2026-03-26 16:06:53 -04:00
committed by GitHub
parent 9736fa5e3d
commit 86764af684
10 changed files with 571 additions and 112 deletions

View File

@@ -1162,35 +1162,10 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
})
.await?;
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
// The parent rollout writer drains asynchronously after turn completion.
// Wait until the persisted JSONL includes the source user turn before forking from it.
let mut source_history_persisted = false;
for _ in 0..100 {
let history = RolloutRecorder::get_rollout_history(&rollout_path).await;
source_history_persisted = history.ok().is_some_and(|history| {
history.get_rollout_items().into_iter().any(|item| {
matches!(
item,
RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. })
if role == "user"
&& content.iter().any(|content_item| {
matches!(
content_item,
ContentItem::InputText { text } if text == "fork seed"
)
})
)
})
});
if source_history_persisted {
break;
}
sleep(StdDuration::from_millis(10)).await;
}
assert!(
source_history_persisted,
"source rollout should contain the completed pre-fork user turn before forking"
);
// Forking reads the persisted rollout JSONL, so force the completed source turn to disk
// before snapshotting from it.
initial.codex.ensure_rollout_materialized().await;
initial.codex.flush_rollout().await;
let mut fork_config = initial.config.clone();
fork_config.permissions.approval_policy =

View File

@@ -74,6 +74,16 @@ impl CodexThread {
self.codex.shutdown_and_wait().await
}
#[doc(hidden)]
pub async fn ensure_rollout_materialized(&self) {
self.codex.session.ensure_rollout_materialized().await;
}
#[doc(hidden)]
pub async fn flush_rollout(&self) {
self.codex.session.flush_rollout().await;
}
pub async fn submit_with_trace(
&self,
op: Op,

View File

@@ -595,6 +595,19 @@ fn windows_restricted_token_supports_full_read_split_write_read_carveouts() {
},
]);
#[cfg(windows)]
let expected_deny_write_paths = vec![
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(cwd.join(".codex"))
.expect("absolute .codex"),
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
.expect("absolute docs"),
];
#[cfg(not(windows))]
let expected_deny_write_paths = vec![
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
.expect("absolute docs"),
];
assert_eq!(
resolve_windows_restricted_token_filesystem_overlay(
SandboxType::WindowsRestrictedToken,
@@ -605,10 +618,7 @@ fn windows_restricted_token_supports_full_read_split_write_read_carveouts() {
WindowsSandboxLevel::RestrictedToken,
),
Ok(Some(WindowsRestrictedTokenFilesystemOverlay {
additional_deny_write_paths: vec![
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
.expect("absolute docs"),
],
additional_deny_write_paths: expected_deny_write_paths,
}))
);
}

View File

@@ -252,3 +252,37 @@ fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() {
SafetyCheck::AskUser,
);
}
#[test]
fn missing_project_dot_codex_config_requires_approval() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_path_buf();
let action =
ApplyPatchAction::new_add_for_test(&cwd.join(".codex").join("config.toml"), "".to_string());
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let file_system_sandbox_policy =
FileSystemSandboxPolicy::from_legacy_sandbox_policy(&sandbox_policy, &cwd);
assert!(!is_write_patch_constrained_to_writable_paths(
&action,
&file_system_sandbox_policy,
&cwd,
));
assert_eq!(
assess_patch_safety(
&action,
AskForApproval::OnRequest,
&sandbox_policy,
&file_system_sandbox_policy,
&cwd,
WindowsSandboxLevel::Disabled,
),
SafetyCheck::AskUser,
);
}