mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
e92c952b2e
commit
e7dda8070e
@@ -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("&"),
|
||||
'<' => rendered.push_str("<"),
|
||||
'>' => rendered.push_str(">"),
|
||||
'"' => rendered.push_str("""),
|
||||
'\'' => rendered.push_str("'"),
|
||||
_ => 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}")));
|
||||
|
||||
Reference in New Issue
Block a user