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}")));
@@ -1,9 +1,21 @@
use crate::shell::ShellType;
use super::*;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::permissions::project_roots_glob_pattern;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::TurnContextItem;
use codex_utils_absolute_path::test_support::PathBufExt;
use core_test_support::test_path_buf;
use pretty_assertions::assert_eq;
use std::path::Path;
use std::path::PathBuf;
fn fake_shell_name() -> String {
@@ -79,6 +91,125 @@ fn serialize_environment_context_with_network() {
assert_eq!(context.render(), expected);
}
fn workspace_write_permission_profile_with_private_denials() -> PermissionProfile {
PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(Some(PathBuf::from("private"))),
},
access: FileSystemAccessMode::Deny,
},
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: project_roots_glob_pattern(Path::new("private/**")),
},
access: FileSystemAccessMode::Deny,
},
]),
NetworkSandboxPolicy::Restricted,
)
}
#[test]
fn serialize_environment_context_with_full_filesystem_profile() {
let repo = test_abs_path("/repo");
let other_repo = test_abs_path("/other-repo");
let repo_private = repo.join("private");
let other_repo_private = other_repo.join("private");
let repo_private_glob =
AbsolutePathBuf::resolve_path_against_base(Path::new("private/**"), repo.as_path());
let other_repo_private_glob =
AbsolutePathBuf::resolve_path_against_base(Path::new("private/**"), other_repo.as_path());
let mut context = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: test_path_buf("/repo").abs(),
shell: fake_shell_name(),
}],
/*current_date*/ None,
/*timezone*/ None,
/*network*/ None,
/*subagents*/ None,
);
context.filesystem = Some(FileSystemContext::from_permission_profile(
&workspace_write_permission_profile_with_private_denials(),
&[repo.clone(), other_repo.clone()],
));
let expected = format!(
r#"<environment_context>
<cwd>{}</cwd>
<shell>bash</shell>
<filesystem><workspace_roots><root>{repo}</root><root>{other_repo}</root></workspace_roots><permission_profile type="managed"><file_system type="restricted"><entry access="write"><path>{repo}</path></entry><entry access="write"><path>{other_repo}</path></entry><entry access="deny" escalatable="false"><path>{repo_private}</path></entry><entry access="deny" escalatable="false"><path>{other_repo_private}</path></entry><entry access="deny" escalatable="false"><glob>{repo_private_glob}</glob></entry><entry access="deny" escalatable="false"><glob>{other_repo_private_glob}</glob></entry></file_system></permission_profile></filesystem>
</environment_context>"#,
test_path_buf("/repo").display(),
repo = repo.to_string_lossy(),
other_repo = other_repo.to_string_lossy(),
repo_private = repo_private.to_string_lossy(),
other_repo_private = other_repo_private.to_string_lossy(),
repo_private_glob = repo_private_glob.to_string_lossy(),
other_repo_private_glob = other_repo_private_glob.to_string_lossy(),
);
assert_eq!(context.render(), expected);
}
#[test]
fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() {
let repo = test_abs_path("/repo");
let other_repo = test_abs_path("/other-repo");
let repo_private = repo.join("private");
let item = TurnContextItem {
turn_id: None,
cwd: test_path_buf("/not-the-workspace"),
workspace_roots: Some(vec![repo.clone(), other_repo.clone()]),
current_date: None,
timezone: None,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: Some(workspace_write_permission_profile_with_private_denials()),
network: None,
file_system_sandbox_policy: None,
model: "gpt-5".to_string(),
personality: None,
collaboration_mode: None,
realtime_active: None,
effort: None,
summary: codex_protocol::config_types::ReasoningSummary::Auto,
};
let context = EnvironmentContext::from_turn_context_item(&item, fake_shell_name()).render();
assert!(
context.contains(&format!(
"<root>{}</root><root>{}</root>",
repo.to_string_lossy(),
other_repo.to_string_lossy()
)),
"{context}"
);
assert!(
context.contains(&format!("<path>{}</path>", repo_private.to_string_lossy())),
"{context}"
);
assert!(
!context.contains(
test_abs_path("/not-the-workspace")
.join("private")
.to_string_lossy()
.as_ref()
),
"{context}"
);
}
#[test]
fn serialize_read_only_environment_context() {
let context = EnvironmentContext::new(
@@ -4,6 +4,7 @@ use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::format_allow_prefixes;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::GranularApprovalConfig;
@@ -68,9 +69,11 @@ impl PermissionsInstructions {
exec_permission_approvals_enabled: bool,
request_permissions_tool_enabled: bool,
) -> Self {
let (sandbox_mode, writable_roots) = sandbox_prompt_from_profile(permission_profile, cwd);
let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy();
let (sandbox_mode, writable_roots) =
sandbox_prompt_from_policy(&file_system_sandbox_policy, cwd);
Self::from_permissions_with_network(
Self::from_permissions_with_network_and_denied_reads(
sandbox_mode,
network_access_from_policy(permission_profile.network_sandbox_policy()),
PermissionsPromptConfig {
@@ -81,14 +84,32 @@ impl PermissionsInstructions {
request_permissions_tool_enabled,
},
writable_roots,
denied_reads_text(&file_system_sandbox_policy, cwd),
)
}
#[cfg(test)]
fn from_permissions_with_network(
sandbox_mode: SandboxMode,
network_access: NetworkAccess,
config: PermissionsPromptConfig<'_>,
writable_roots: Option<Vec<WritableRoot>>,
) -> Self {
Self::from_permissions_with_network_and_denied_reads(
sandbox_mode,
network_access,
config,
writable_roots,
/*denied_reads*/ None,
)
}
fn from_permissions_with_network_and_denied_reads(
sandbox_mode: SandboxMode,
network_access: NetworkAccess,
config: PermissionsPromptConfig<'_>,
writable_roots: Option<Vec<WritableRoot>>,
denied_reads: Option<String>,
) -> Self {
let mut text = String::new();
append_section(&mut text, &sandbox_text(sandbox_mode, network_access));
@@ -105,6 +126,9 @@ impl PermissionsInstructions {
if let Some(writable_roots) = writable_roots_text(writable_roots) {
append_section(&mut text, &writable_roots);
}
if let Some(denied_reads) = denied_reads {
append_section(&mut text, &denied_reads);
}
if !text.ends_with('\n') {
text.push('\n');
}
@@ -112,27 +136,19 @@ impl PermissionsInstructions {
}
}
fn sandbox_prompt_from_profile(
permission_profile: &PermissionProfile,
fn sandbox_prompt_from_policy(
file_system_policy: &FileSystemSandboxPolicy,
cwd: &Path,
) -> (SandboxMode, Option<Vec<WritableRoot>>) {
match permission_profile {
PermissionProfile::Disabled | PermissionProfile::External { .. } => {
(SandboxMode::DangerFullAccess, None)
}
PermissionProfile::Managed { .. } => {
let file_system_policy = permission_profile.file_system_sandbox_policy();
if file_system_policy.has_full_disk_write_access() {
return (SandboxMode::DangerFullAccess, None);
}
if file_system_policy.has_full_disk_write_access() {
return (SandboxMode::DangerFullAccess, None);
}
let writable_roots = file_system_policy.get_writable_roots_with_cwd(cwd);
if writable_roots.is_empty() {
(SandboxMode::ReadOnly, None)
} else {
(SandboxMode::WorkspaceWrite, Some(writable_roots))
}
}
let writable_roots = file_system_policy.get_writable_roots_with_cwd(cwd);
if writable_roots.is_empty() {
(SandboxMode::ReadOnly, None)
} else {
(SandboxMode::WorkspaceWrite, Some(writable_roots))
}
}
@@ -254,6 +270,28 @@ fn writable_roots_text(writable_roots: Option<Vec<WritableRoot>>) -> Option<Stri
})
}
fn denied_reads_text(file_system_policy: &FileSystemSandboxPolicy, cwd: &Path) -> Option<String> {
let mut entries = file_system_policy
.get_unreadable_roots_with_cwd(cwd)
.into_iter()
.map(|root| format!("- path `{}`", root.to_string_lossy()))
.collect::<Vec<_>>();
entries.extend(
file_system_policy
.get_unreadable_globs_with_cwd(cwd)
.into_iter()
.map(|glob| format!("- glob `{glob}`")),
);
if entries.is_empty() {
return None;
}
Some(format!(
"## Denied filesystem reads\nThe active permission profile denies reading these paths/globs. Do not request escalation or additional permissions to read them; these denials are policy restrictions.\n{}",
entries.join("\n")
))
}
fn approved_command_prefixes_text(exec_policy: &Policy) -> Option<String> {
format_allow_prefixes(exec_policy.get_allowed_prefixes())
.filter(|prefixes| !prefixes.is_empty())
@@ -6,6 +6,7 @@ use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
@@ -83,6 +84,52 @@ fn builds_permissions_from_profile() {
assert!(text.contains(writable_root.to_string_lossy().as_ref()));
}
#[test]
fn builds_permissions_from_profile_with_denied_reads() {
let cwd = test_path_buf("/tmp");
let denied_root =
AbsolutePathBuf::from_absolute_path(cwd.join("blocked")).expect("absolute path");
let denied_glob = cwd.join("blocked").join("**");
let permission_profile = PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: denied_root.clone(),
},
access: FileSystemAccessMode::Deny,
},
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: denied_glob.to_string_lossy().into_owned(),
},
access: FileSystemAccessMode::Deny,
},
]),
NetworkSandboxPolicy::Restricted,
);
let instructions = PermissionsInstructions::from_permission_profile(
&permission_profile,
AskForApproval::OnRequest,
ApprovalsReviewer::AutoReview,
&Policy::empty(),
&cwd,
/*exec_permission_approvals_enabled*/ false,
/*request_permissions_tool_enabled*/ false,
);
let text = instructions.body();
assert!(text.contains("## Denied filesystem reads"));
assert!(text.contains("Do not request escalation or additional permissions"));
assert!(text.contains(denied_root.to_string_lossy().as_ref()));
assert!(text.contains(&format!("glob `{}`", denied_glob.to_string_lossy())));
}
#[test]
fn includes_request_rule_instructions_for_on_request() {
let mut exec_policy = Policy::empty();
@@ -121,6 +121,7 @@ fn reference_context_item() -> TurnContextItem {
TurnContextItem {
turn_id: Some("reference-turn".to_string()),
cwd: PathBuf::from("/tmp/reference-cwd"),
workspace_roots: None,
current_date: Some("2026-03-23".to_string()),
timezone: Some("America/Los_Angeles".to_string()),
approval_policy: AskForApproval::OnRequest,
+2
View File
@@ -149,6 +149,8 @@ use prompt::GuardianTranscriptEntryKind;
#[cfg(test)]
use prompt::build_guardian_prompt_items;
#[cfg(test)]
use prompt::build_guardian_prompt_items_with_parent_turn;
#[cfg(test)]
use prompt::collect_guardian_transcript_entries;
#[cfg(test)]
use prompt::guardian_output_schema;
+49
View File
@@ -10,6 +10,7 @@ use serde_json::Value;
use crate::compact::content_items_to_text;
use crate::event_mapping::is_contextual_user_message_content;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use codex_utils_output_truncation::approx_bytes_for_tokens;
use codex_utils_output_truncation::approx_token_count;
use codex_utils_output_truncation::approx_tokens_from_byte_count;
@@ -86,11 +87,29 @@ pub(crate) enum GuardianPromptMode {
/// Split the variable request into separate user content items so the
/// Responses request snapshot shows clear boundaries while preserving exact
/// prompt text through trailing newlines.
#[cfg(test)]
pub(crate) async fn build_guardian_prompt_items(
session: &Session,
retry_reason: Option<String>,
request: GuardianApprovalRequest,
mode: GuardianPromptMode,
) -> serde_json::Result<GuardianPromptItems> {
build_guardian_prompt_items_with_parent_turn(
session,
/*parent_turn*/ None,
retry_reason,
request,
mode,
)
.await
}
pub(crate) async fn build_guardian_prompt_items_with_parent_turn(
session: &Session,
parent_turn: Option<&TurnContext>,
retry_reason: Option<String>,
request: GuardianApprovalRequest,
mode: GuardianPromptMode,
) -> serde_json::Result<GuardianPromptItems> {
let history = session.clone_history().await;
let transcript_entries = collect_guardian_transcript_entries(history.raw_items());
@@ -172,6 +191,11 @@ pub(crate) async fn build_guardian_prompt_items(
if let Some(note) = omission_note {
push_text(format!("\n{note}\n"));
}
if let Some(denied_reads_context) = parent_turn.and_then(parent_turn_denied_reads_context) {
push_text("\n>>> PARENT TURN PERMISSION CONTEXT START\n".to_string());
push_text(denied_reads_context);
push_text(">>> PARENT TURN PERMISSION CONTEXT END\n".to_string());
}
match &request {
GuardianApprovalRequest::NetworkAccess { trigger, .. } => {
push_text(">>> APPROVAL REQUEST START\n".to_string());
@@ -216,6 +240,31 @@ pub(crate) async fn build_guardian_prompt_items(
})
}
fn parent_turn_denied_reads_context(turn: &TurnContext) -> Option<String> {
#[allow(deprecated)]
let cwd = &turn.cwd;
let file_system_policy = turn.permission_profile.file_system_sandbox_policy();
let mut entries = file_system_policy
.get_unreadable_roots_with_cwd(cwd)
.into_iter()
.map(|root| format!("- path `{}`", root.to_string_lossy()))
.collect::<Vec<_>>();
entries.extend(
file_system_policy
.get_unreadable_globs_with_cwd(cwd)
.into_iter()
.map(|glob| format!("- glob `{glob}`")),
);
if entries.is_empty() {
return None;
}
Some(format!(
"The parent turn's active permission profile denies reading these paths/globs. These are policy restrictions; do not approve escalation whose purpose is to read them.\n{}\n",
entries.join("\n")
))
}
enum GuardianPromptShape {
Full,
Delta { already_seen_entry_count: usize },
+3 -2
View File
@@ -51,7 +51,7 @@ use super::GUARDIAN_REVIEWER_NAME;
use super::GuardianApprovalRequest;
use super::prompt::GuardianPromptMode;
use super::prompt::GuardianTranscriptCursor;
use super::prompt::build_guardian_prompt_items;
use super::prompt::build_guardian_prompt_items_with_parent_turn;
use super::prompt::guardian_policy_prompt;
use super::prompt::guardian_policy_prompt_with_config;
@@ -686,8 +686,9 @@ async fn run_review_on_session(
)
.await;
build_guardian_prompt_items(
build_guardian_prompt_items_with_parent_turn(
params.parent_session.as_ref(),
Some(params.parent_turn.as_ref()),
params.retry_reason.clone(),
params.request.clone(),
prompt_mode,
+63
View File
@@ -33,6 +33,11 @@ use codex_protocol::models::ContentItem;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
@@ -357,6 +362,64 @@ async fn build_guardian_prompt_full_mode_preserves_initial_review_format() -> an
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn build_guardian_prompt_includes_parent_turn_denied_reads() -> anyhow::Result<()> {
let (mut session, mut turn) = crate::session::tests::make_session_and_context().await;
session.conversation_id = fixed_guardian_parent_session_id();
let denied_root = test_path_buf("/repo/private").abs();
let denied_glob = test_path_buf("/repo/private/**").display().to_string();
turn.permission_profile = PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: denied_root.clone(),
},
access: FileSystemAccessMode::Deny,
},
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: denied_glob.clone(),
},
access: FileSystemAccessMode::Deny,
},
]),
NetworkSandboxPolicy::Restricted,
);
let session = Arc::new(session);
let turn = Arc::new(turn);
seed_guardian_parent_history(&session, &turn).await;
let prompt = build_guardian_prompt_items_with_parent_turn(
session.as_ref(),
Some(turn.as_ref()),
Some("Sandbox denied reading /repo/private/secret.txt.".to_string()),
GuardianApprovalRequest::Shell {
id: "shell-1".to_string(),
command: vec!["cat".to_string(), "/repo/private/secret.txt".to_string()],
cwd: test_path_buf("/repo").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::RequireEscalated,
additional_permissions: None,
justification: Some("Need to inspect the secret file.".to_string()),
},
GuardianPromptMode::Full,
)
.await?;
let text = guardian_prompt_text(&prompt.items);
assert!(text.contains("PARENT TURN PERMISSION CONTEXT START"));
assert!(text.contains("do not approve escalation whose purpose is to read them"));
assert!(text.contains(denied_root.to_string_lossy().as_ref()));
assert!(text.contains(&format!("glob `{denied_glob}`")));
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn build_guardian_prompt_delta_mode_preserves_original_numbering() -> anyhow::Result<()> {
let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await;
@@ -61,6 +61,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
turn_id: Some(turn_context.sub_id.clone()),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
workspace_roots: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -98,6 +99,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
turn_id: Some(turn_context.sub_id.clone()),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
workspace_roots: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -946,6 +948,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
turn_id: Some(turn_context.sub_id.clone()),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
workspace_roots: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -1023,6 +1026,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
turn_id: Some(turn_context.sub_id.clone()),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
workspace_roots: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -1050,6 +1054,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
turn_id: Some(turn_context.sub_id.clone()),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
workspace_roots: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -1167,6 +1172,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
turn_id: Some(current_turn_id.clone()),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
workspace_roots: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -1283,6 +1289,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
turn_id: Some(turn_context.sub_id.clone()),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
workspace_roots: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -1440,6 +1447,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
turn_id: Some(turn_context.sub_id.clone()),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
workspace_roots: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
+1
View File
@@ -2460,6 +2460,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
turn_id: Some(turn_context.sub_id.clone()),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
workspace_roots: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -322,10 +322,12 @@ impl TurnContext {
}
pub(crate) fn to_turn_context_item(&self) -> TurnContextItem {
let workspace_roots = self.config.effective_workspace_roots();
TurnContextItem {
turn_id: Some(self.sub_id.clone()),
#[allow(deprecated)]
cwd: self.cwd.to_path_buf(),
workspace_roots: (!workspace_roots.is_empty()).then_some(workspace_roots),
current_date: self.current_date.clone(),
timezone: self.timezone.clone(),
approval_policy: self.approval_policy.value(),
+37 -4
View File
@@ -46,10 +46,7 @@ fn text_user_input_parts(texts: Vec<String>) -> serde_json::Value {
}
fn assert_default_env_context(text: &str, cwd: &str) {
assert!(
text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG),
"expected environment context fragment: {text}"
);
assert_env_context_fragment(text);
assert!(
text.contains(&format!("<cwd>{cwd}</cwd>")),
"expected cwd in environment context: {text}"
@@ -58,6 +55,13 @@ fn assert_default_env_context(text: &str, cwd: &str) {
text.contains(&format!("<shell>{}</shell>", default_user_shell().name())),
"expected shell in environment context: {text}"
);
}
fn assert_env_context_fragment(text: &str) {
assert!(
text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG),
"expected environment context fragment: {text}"
);
assert!(
text.contains("<current_date>") && text.contains("</current_date>"),
"expected current_date in environment context: {text}"
@@ -502,8 +506,24 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an
expected_permissions_msg_2, expected_permissions_msg,
"expected updated permissions message after override"
);
let expected_env_msg_2 = body2["input"][body1_input.len() + 1].clone();
assert_eq!(expected_env_msg_2["role"].as_str(), Some("user"));
let env_text = expected_env_msg_2["content"][0]["text"]
.as_str()
.expect("environment context text");
assert_env_context_fragment(env_text);
assert!(
env_text.contains("<permission_profile type=\"managed\">")
&& env_text.contains("<file_system type=\"restricted\">")
&& env_text.contains(&format!(
"<entry access=\"write\"><path>{}</path></entry>",
writable.abs().display()
)),
"expected workspace-write filesystem profile in environment context: {env_text}"
);
let mut expected_body2 = body1_input.to_vec();
expected_body2.push(expected_permissions_msg_2);
expected_body2.push(expected_env_msg_2);
expected_body2.push(expected_user_message_2);
assert_eq!(body2["input"], serde_json::Value::Array(expected_body2));
@@ -1086,12 +1106,25 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
}),
"expected model switch section after model override: {expected_settings_update_msg:?}"
);
let expected_env_update_msg = body2["input"][body1_input.len() + 1].clone();
assert_eq!(expected_env_update_msg["role"].as_str(), Some("user"));
let expected_env_update_text = expected_env_update_msg["content"][0]["text"]
.as_str()
.expect("environment context text");
assert_env_context_fragment(expected_env_update_text);
assert!(
expected_env_update_text.contains(
"<permission_profile type=\"disabled\"><file_system type=\"unrestricted\" /></permission_profile>",
),
"expected disabled filesystem profile in environment context: {expected_env_update_text}"
);
let expected_user_message_2 = text_user_input("hello 2".to_string());
let expected_input_2 = serde_json::Value::Array(vec![
expected_permissions_msg,
expected_contextual_user_msg_1,
expected_user_message_1,
expected_settings_update_msg,
expected_env_update_msg,
expected_user_message_2,
]);
assert_eq!(body2["input"], expected_input_2);
@@ -28,6 +28,7 @@ fn resume_history(
let turn_ctx = TurnContextItem {
turn_id: Some(turn_id.clone()),
cwd: config.cwd.to_path_buf(),
workspace_roots: None,
current_date: None,
timezone: None,
approval_policy: config.permissions.approval_policy.value(),
+5
View File
@@ -2772,6 +2772,10 @@ pub struct TurnContextItem {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_id: Option<String>,
pub cwd: PathBuf,
/// Effective workspace roots used to materialize symbolic
/// `:workspace_roots` filesystem permissions in `permission_profile`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_roots: Option<Vec<AbsolutePathBuf>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -5212,6 +5216,7 @@ mod tests {
let item = TurnContextItem {
turn_id: None,
cwd: test_path_buf("/tmp"),
workspace_roots: None,
current_date: None,
timezone: None,
approval_policy: AskForApproval::Never,
+1
View File
@@ -1129,6 +1129,7 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re
item: RolloutItem::TurnContext(TurnContextItem {
turn_id: Some("turn-1".to_string()),
cwd: latest_cwd.clone(),
workspace_roots: None,
current_date: None,
timezone: None,
approval_policy: AskForApproval::Never,
+3
View File
@@ -343,6 +343,7 @@ mod tests {
&RolloutItem::TurnContext(TurnContextItem {
turn_id: Some("turn-1".to_string()),
cwd: PathBuf::from("/parent/workspace"),
workspace_roots: None,
current_date: None,
timezone: None,
approval_policy: AskForApproval::Never,
@@ -378,6 +379,7 @@ mod tests {
&RolloutItem::TurnContext(TurnContextItem {
turn_id: Some("turn-1".to_string()),
cwd: PathBuf::from("/fallback/workspace"),
workspace_roots: None,
current_date: None,
timezone: None,
approval_policy: AskForApproval::OnRequest,
@@ -407,6 +409,7 @@ mod tests {
&RolloutItem::TurnContext(TurnContextItem {
turn_id: Some("turn-1".to_string()),
cwd: PathBuf::from("/fallback/workspace"),
workspace_roots: None,
current_date: None,
timezone: None,
approval_policy: AskForApproval::OnRequest,