Surface filesystem permission profiles in prompt context (#23924)

## Summary
Some permission profiles can encode filesystem reads that should remain
unavailable to the agent. Before this change, the model-visible context
and automatic approval review prompt summarized the effective
permissions as a legacy sandbox mode, which can omit permission-profile
filesystem entries from escalation decisions.

For example, a profile can grant workspace access while denying a
private subtree across every workspace root:

```toml
default_permissions = "restricted-workspace"

[permissions.restricted-workspace.workspace_roots]
"/Users/alice/project" = true
"/Users/alice/other-project" = true

[permissions.restricted-workspace.filesystem]
":minimal" = "read"

[permissions.restricted-workspace.filesystem.":workspace_roots"]
"." = "write"
"private" = "deny"
"private/**" = "deny"
```

The context window now describes the workspace roots and effective
filesystem side of the `PermissionProfile` directly, with deny entries
marked as non-escalatable:

```xml
<environment_context>
  <cwd>/Users/alice/project</cwd>
  <shell>zsh</shell>
  <filesystem><workspace_roots><root>/Users/alice/project</root><root>/Users/alice/other-project</root></workspace_roots><permission_profile type="managed"><file_system type="restricted"><entry access="read"><special>:minimal</special></entry><entry access="write"><path>/Users/alice/project</path></entry><entry access="write"><path>/Users/alice/other-project</path></entry><entry access="deny" escalatable="false"><path>/Users/alice/project/private</path></entry><entry access="deny" escalatable="false"><path>/Users/alice/other-project/private</path></entry><entry access="deny" escalatable="false"><glob>/Users/alice/project/private/**</glob></entry><entry access="deny" escalatable="false"><glob>/Users/alice/other-project/private/**</glob></entry></file_system></permission_profile></filesystem>
</environment_context>
```

Managed requirements can impose the same kind of deny-read restriction:

```toml
[permissions.filesystem]
deny_read = [
  "/Users/alice/project/private",
  "/Users/alice/project/private/**",
]
```

The automatic approval review prompt also receives the parent turn's
denied-read context, so review decisions can account for the active
permission profile.

## What Changed
- Render the effective filesystem profile in `<environment_context>`,
including profile type, filesystem entries, workspace roots, and
non-escalatable deny entries.
- Persist effective `workspace_roots` in `TurnContextItem` so
resumed/replayed context does not have to bind `:workspace_roots`
through legacy `cwd` fallback.
- Add explicit permission instructions that denied reads are policy
restrictions, not escalation targets.
- Pass the parent turn's denied-read context into automatic approval
reviews.
- Add targeted coverage for prompt rendering, workspace-root
materialization, replay context, and review prompt context.
- Keep the prompt-context test expectations platform-aware so the same
filesystem rendering assertions pass on Unix and Windows paths.

## Testing
- `just test -p codex-core
context::environment_context::tests::serialize_environment_context_with_full_filesystem_profile`
- `just test -p codex-core
context::environment_context::tests::turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd`
- `just test -p codex-core
context::permissions_instructions::permissions_instructions_tests::builds_permissions_from_profile_with_denied_reads`
- `just fix -p codex-core`

I also attempted `just test -p codex-core`; the changed prompt-context
tests passed, but the full local run did not complete cleanly in this
sandboxed macOS environment due unrelated user-shell `CODEX_SANDBOX*`
expectations and integration-test timeouts.
This commit is contained in:
Michael Bolin
2026-05-28 14:56:53 -07:00
committed by GitHub
Unverified
parent e92c952b2e
commit e7dda8070e
17 changed files with 673 additions and 30 deletions
@@ -1,9 +1,17 @@
use crate::session::turn_context::TurnContext;
use crate::session::turn_context::TurnEnvironment;
use crate::shell::Shell;
use codex_protocol::models::ManagedFileSystemPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::TurnContextItem;
use codex_protocol::protocol::TurnContextNetworkItem;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashSet;
use std::path::PathBuf;
use super::ContextualUserFragment;
@@ -13,6 +21,7 @@ pub(crate) struct EnvironmentContext {
pub(crate) current_date: Option<String>,
pub(crate) timezone: Option<String>,
pub(crate) network: Option<NetworkContext>,
pub(crate) filesystem: Option<FileSystemContext>,
pub(crate) subagents: Option<String>,
}
@@ -83,6 +92,208 @@ impl EnvironmentContextEnvironments {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FileSystemContext {
workspace_roots: Vec<String>,
permission_profile: FileSystemPermissionProfileContext,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum FileSystemPermissionProfileContext {
Managed(ManagedFileSystemContext),
Disabled,
External,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ManagedFileSystemContext {
Restricted {
entries: Vec<FileSystemSandboxEntry>,
glob_scan_max_depth: Option<usize>,
},
Unrestricted,
}
impl FileSystemContext {
fn from_permission_profile(
permission_profile: &PermissionProfile,
workspace_roots: &[AbsolutePathBuf],
) -> Self {
let permission_profile = permission_profile
.clone()
.materialize_project_roots_with_workspace_roots(workspace_roots);
let workspace_roots = workspace_roots
.iter()
.map(|root| root.to_string_lossy().into_owned())
.collect();
let permission_profile = match permission_profile {
PermissionProfile::Managed { file_system, .. } => {
FileSystemPermissionProfileContext::Managed(ManagedFileSystemContext::from(
file_system,
))
}
PermissionProfile::Disabled => FileSystemPermissionProfileContext::Disabled,
PermissionProfile::External { .. } => FileSystemPermissionProfileContext::External,
};
Self {
workspace_roots,
permission_profile,
}
}
fn render(&self) -> String {
let mut rendered = "<filesystem>".to_string();
if !self.workspace_roots.is_empty() {
rendered.push_str("<workspace_roots>");
for root in &self.workspace_roots {
push_text_element(&mut rendered, "root", root);
}
rendered.push_str("</workspace_roots>");
}
self.permission_profile.render(&mut rendered);
rendered.push_str("</filesystem>");
rendered
}
}
impl From<ManagedFileSystemPermissions> for ManagedFileSystemContext {
fn from(file_system: ManagedFileSystemPermissions) -> Self {
match file_system {
ManagedFileSystemPermissions::Restricted {
mut entries,
glob_scan_max_depth,
} => {
dedupe_file_system_entries(&mut entries);
Self::Restricted {
entries,
glob_scan_max_depth: glob_scan_max_depth.map(usize::from),
}
}
ManagedFileSystemPermissions::Unrestricted => Self::Unrestricted,
}
}
}
impl FileSystemPermissionProfileContext {
fn render(&self, rendered: &mut String) {
match self {
Self::Managed(file_system) => {
rendered.push_str("<permission_profile type=\"managed\">");
file_system.render(rendered);
rendered.push_str("</permission_profile>");
}
Self::Disabled => {
rendered.push_str(
"<permission_profile type=\"disabled\"><file_system type=\"unrestricted\" /></permission_profile>",
);
}
Self::External => {
rendered.push_str(
"<permission_profile type=\"external\"><file_system type=\"external\" /></permission_profile>",
);
}
}
}
}
impl ManagedFileSystemContext {
fn render(&self, rendered: &mut String) {
match self {
Self::Restricted {
entries,
glob_scan_max_depth,
} => {
if entries.is_empty() && glob_scan_max_depth.is_none() {
rendered.push_str("<file_system type=\"restricted\" />");
return;
}
rendered.push_str("<file_system type=\"restricted\"");
if let Some(glob_scan_max_depth) = glob_scan_max_depth {
rendered.push_str(&format!(" glob_scan_max_depth=\"{glob_scan_max_depth}\""));
}
rendered.push('>');
for entry in entries {
render_file_system_entry(rendered, entry);
}
rendered.push_str("</file_system>");
}
Self::Unrestricted => {
rendered.push_str("<file_system type=\"unrestricted\" />");
}
}
}
}
fn render_file_system_entry(rendered: &mut String, entry: &FileSystemSandboxEntry) {
rendered.push_str("<entry access=\"");
let access = entry.access.to_string();
rendered.push_str(&access);
if entry.access == FileSystemAccessMode::Deny {
rendered.push_str("\" escalatable=\"false");
}
rendered.push_str("\">");
match &entry.path {
FileSystemPath::Path { path } => {
push_text_element(rendered, "path", path.to_string_lossy().as_ref());
}
FileSystemPath::GlobPattern { pattern } => {
push_text_element(rendered, "glob", pattern);
}
FileSystemPath::Special { value } => {
let value = render_special_path(value);
push_text_element(rendered, "special", &value);
}
}
rendered.push_str("</entry>");
}
fn render_special_path(value: &FileSystemSpecialPath) -> String {
match value {
FileSystemSpecialPath::Root => ":root".to_string(),
FileSystemSpecialPath::Minimal => ":minimal".to_string(),
FileSystemSpecialPath::ProjectRoots { subpath } => {
render_special_path_with_subpath(":workspace_roots", subpath)
}
FileSystemSpecialPath::Tmpdir => ":tmpdir".to_string(),
FileSystemSpecialPath::SlashTmp => ":slash_tmp".to_string(),
FileSystemSpecialPath::Unknown { path, subpath } => {
render_special_path_with_subpath(path, subpath)
}
}
}
fn render_special_path_with_subpath(base: &str, subpath: &Option<PathBuf>) -> String {
match subpath {
Some(subpath) => format!("{base}/{}", subpath.display()),
None => base.to_string(),
}
}
fn dedupe_file_system_entries(entries: &mut Vec<FileSystemSandboxEntry>) {
let mut seen = HashSet::new();
entries.retain(|entry| seen.insert(entry.clone()));
}
fn push_text_element(rendered: &mut String, name: &str, value: &str) {
rendered.push_str(&format!("<{name}>"));
push_xml_escaped_text(rendered, value);
rendered.push_str(&format!("</{name}>"));
}
fn push_xml_escaped_text(rendered: &mut String, value: &str) {
for ch in value.chars() {
match ch {
'&' => rendered.push_str("&amp;"),
'<' => rendered.push_str("&lt;"),
'>' => rendered.push_str("&gt;"),
'"' => rendered.push_str("&quot;"),
'\'' => rendered.push_str("&apos;"),
_ => rendered.push(ch),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct NetworkContext {
allowed_domains: Vec<String>,
@@ -129,6 +340,7 @@ impl EnvironmentContext {
current_date,
timezone,
network,
filesystem: None,
subagents,
}
}
@@ -138,6 +350,7 @@ impl EnvironmentContext {
current_date: Option<String>,
timezone: Option<String>,
network: Option<NetworkContext>,
filesystem: Option<FileSystemContext>,
subagents: Option<String>,
) -> Self {
Self {
@@ -145,6 +358,7 @@ impl EnvironmentContext {
current_date,
timezone,
network,
filesystem,
subagents,
}
}
@@ -157,6 +371,7 @@ impl EnvironmentContext {
&& self.current_date == other.current_date
&& self.timezone == other.timezone
&& self.network == other.network
&& self.filesystem == other.filesystem
&& self.subagents == other.subagents
}
@@ -165,6 +380,7 @@ impl EnvironmentContext {
after: &EnvironmentContext,
) -> Self {
let before_network = Self::network_from_turn_context_item(before);
let before_filesystem = Self::filesystem_from_turn_context_item(before);
let environments = match &after.environments {
EnvironmentContextEnvironments::Single(environment) => {
if before.cwd.as_path() != environment.cwd.as_path() {
@@ -186,17 +402,23 @@ impl EnvironmentContext {
} else {
before_network
};
let filesystem = if before_filesystem != after.filesystem {
after.filesystem.clone()
} else {
before_filesystem
};
EnvironmentContext::new_with_environments(
environments,
after.current_date.clone(),
after.timezone.clone(),
network,
filesystem,
/*subagents*/ None,
)
}
pub(crate) fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self {
Self::new(
let mut context = Self::new(
EnvironmentContextEnvironment::from_turn_environments(
&turn_context.environments.turn_environments,
shell,
@@ -205,7 +427,12 @@ impl EnvironmentContext {
turn_context.timezone.clone(),
Self::network_from_turn_context(turn_context),
/*subagents*/ None,
)
);
context.filesystem = Some(FileSystemContext::from_permission_profile(
&turn_context.permission_profile,
&turn_context.config.effective_workspace_roots(),
));
context
}
pub(crate) fn from_turn_context_item(
@@ -216,11 +443,14 @@ impl EnvironmentContext {
Ok(cwd) => cwd,
Err(_) => AbsolutePathBuf::resolve_path_against_base(&turn_context_item.cwd, "/"),
};
Self::new(
vec![EnvironmentContextEnvironment::legacy(cwd, shell)],
Self::new_with_environments(
EnvironmentContextEnvironments::from_vec(vec![EnvironmentContextEnvironment::legacy(
cwd, shell,
)]),
turn_context_item.current_date.clone(),
turn_context_item.timezone.clone(),
Self::network_from_turn_context_item(turn_context_item),
Self::filesystem_from_turn_context_item(turn_context_item),
/*subagents*/ None,
)
}
@@ -266,6 +496,30 @@ impl EnvironmentContext {
denied_domains.clone(),
))
}
fn filesystem_from_turn_context_item(
turn_context_item: &TurnContextItem,
) -> Option<FileSystemContext> {
Some(FileSystemContext::from_permission_profile(
&turn_context_item.permission_profile(),
&workspace_roots_from_turn_context_item(turn_context_item),
))
}
}
fn workspace_roots_from_turn_context_item(
turn_context_item: &TurnContextItem,
) -> Vec<AbsolutePathBuf> {
if let Some(workspace_roots) = turn_context_item.workspace_roots.as_ref() {
return workspace_roots.clone();
}
// Older rollout items did not persist workspace roots. Fall back to the
// legacy cwd binding only when reconstructing that historical context.
match AbsolutePathBuf::try_from(turn_context_item.cwd.clone()) {
Ok(cwd) => vec![cwd],
Err(_) => Vec::new(),
}
}
impl ContextualUserFragment for EnvironmentContext {
@@ -324,6 +578,9 @@ impl ContextualUserFragment for EnvironmentContext {
// lines.push(" <network enabled=\"false\" />".to_string());
}
}
if let Some(filesystem) = &self.filesystem {
lines.push(format!(" {}", filesystem.render()));
}
if let Some(subagents) = &self.subagents {
lines.push(" <subagents>".to_string());
lines.extend(subagents.lines().map(|line| format!(" {line}")));