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}")));
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user