mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[sandbox] Enforce protected workspace metadata paths (#19846)
## Summary Make FileSystemSandboxPolicy the semantic source of truth for project root metadata protection. Under writable roots, `.git`, `.codex`, and `.agents` stay protected unless user policy grants an explicit write rule for that metadata path. ## Scope 1. Add `protected_metadata_names` to `WritableRoot`. 2. Teach `FileSystemSandboxPolicy::can_write_path_with_cwd` to reject protected metadata writes under writable roots unless explicitly allowed. 3. Default workspace write profiles to protect `.git`, `.codex`, and `.agents`. 4. Add the Linux fallback setup needed before Linux enforcement lands later in the stack. ## Reviewer Focus 1. The policy decision belongs in FileSystemSandboxPolicy, not shell command parsing. 2. Legacy SandboxPolicy remains a compatibility projection, not the source of the new rule. 3. Explicit user write rules can still opt into these metadata paths. ## Stack 1. Policy primitive: this PR 2. macOS Seatbelt adapter: #19847 3. Shell preflight UX: #19848 4. Runtime profile propagation: #19849 5. Linux bubblewrap adapter: #19852 ## Validation 1. codex protocol permissions tests 2. formatting for codex protocol and codex linux sandbox 3. diff whitespace check
This commit is contained in:
@@ -259,6 +259,7 @@ fn create_filesystem_args(
|
||||
writable_roots.push(WritableRoot {
|
||||
root: AbsolutePathBuf::from_absolute_path("/")?,
|
||||
read_only_subpaths: Vec::new(),
|
||||
protected_metadata_names: Vec::new(),
|
||||
});
|
||||
}
|
||||
let missing_auto_metadata_read_only_project_root_subpaths: HashSet<PathBuf> =
|
||||
|
||||
@@ -19,6 +19,62 @@ use crate::protocol::NetworkAccess;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::protocol::WritableRoot;
|
||||
|
||||
const PROTECTED_METADATA_GIT_PATH_NAME: &str = ".git";
|
||||
const PROTECTED_METADATA_AGENTS_PATH_NAME: &str = ".agents";
|
||||
const PROTECTED_METADATA_CODEX_PATH_NAME: &str = ".codex";
|
||||
|
||||
/// Top-level workspace metadata paths that stay protected under writable roots.
|
||||
pub const PROTECTED_METADATA_PATH_NAMES: &[&str] = &[
|
||||
PROTECTED_METADATA_GIT_PATH_NAME,
|
||||
PROTECTED_METADATA_AGENTS_PATH_NAME,
|
||||
PROTECTED_METADATA_CODEX_PATH_NAME,
|
||||
];
|
||||
|
||||
/// Returns true when a path basename is one of the protected workspace metadata names.
|
||||
pub fn is_protected_metadata_name(name: &OsStr) -> bool {
|
||||
PROTECTED_METADATA_PATH_NAMES
|
||||
.iter()
|
||||
.any(|metadata_name| name == OsStr::new(metadata_name))
|
||||
}
|
||||
|
||||
pub fn is_protected_metadata_directory_name(name: &OsStr) -> bool {
|
||||
name == OsStr::new(PROTECTED_METADATA_AGENTS_PATH_NAME)
|
||||
|| name == OsStr::new(PROTECTED_METADATA_CODEX_PATH_NAME)
|
||||
}
|
||||
|
||||
/// Returns the protected workspace metadata name when an agent write to `path`
|
||||
/// should be blocked before execution.
|
||||
pub fn forbidden_agent_metadata_write(
|
||||
path: &Path,
|
||||
cwd: &Path,
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
) -> Option<&'static str> {
|
||||
if !matches!(
|
||||
file_system_sandbox_policy.kind,
|
||||
FileSystemSandboxKind::Restricted
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let target = resolve_candidate_path(path, cwd)?;
|
||||
let (protected_metadata_path, metadata_name) =
|
||||
metadata_child_of_writable_root(file_system_sandbox_policy, target.as_path(), cwd)?;
|
||||
if has_explicit_write_entry_for_metadata_path(
|
||||
file_system_sandbox_policy,
|
||||
&protected_metadata_path,
|
||||
target.as_path(),
|
||||
cwd,
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !file_system_sandbox_policy.can_write_path_with_cwd(target.as_path(), cwd) {
|
||||
return Some(metadata_name);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS,
|
||||
)]
|
||||
@@ -570,7 +626,35 @@ impl FileSystemSandboxPolicy {
|
||||
}
|
||||
|
||||
pub fn can_write_path_with_cwd(&self, path: &Path, cwd: &Path) -> bool {
|
||||
self.resolve_access_with_cwd(path, cwd).can_write()
|
||||
if !self.resolve_access_with_cwd(path, cwd).can_write() {
|
||||
return false;
|
||||
}
|
||||
if self.has_full_disk_write_access() {
|
||||
return true;
|
||||
}
|
||||
!self.is_metadata_write_denied(path, cwd)
|
||||
}
|
||||
|
||||
fn is_metadata_write_denied(&self, path: &Path, cwd: &Path) -> bool {
|
||||
if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(target) = resolve_candidate_path(path, cwd) else {
|
||||
return true;
|
||||
};
|
||||
let Some((protected_metadata_path, _)) =
|
||||
metadata_child_of_writable_root(self, target.as_path(), cwd)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
!has_explicit_write_entry_for_metadata_path(
|
||||
self,
|
||||
&protected_metadata_path,
|
||||
target.as_path(),
|
||||
cwd,
|
||||
)
|
||||
}
|
||||
|
||||
/// Replaces symbolic `:project_roots` entries with absolute paths resolved
|
||||
@@ -688,6 +772,10 @@ impl FileSystemSandboxPolicy {
|
||||
return true;
|
||||
};
|
||||
|
||||
if protected_metadata_names_need_direct_runtime_enforcement(self, &legacy_policy, cwd) {
|
||||
return true;
|
||||
}
|
||||
|
||||
self.semantic_signature(cwd)
|
||||
!= legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd)
|
||||
.semantic_signature(cwd)
|
||||
@@ -749,6 +837,8 @@ impl FileSystemSandboxPolicy {
|
||||
.iter()
|
||||
.filter(|path| normalize_effective_absolute_path((*path).clone()) == root)
|
||||
.collect();
|
||||
let protected_metadata_names =
|
||||
protected_metadata_names_for_writable_root(self, &root, &raw_writable_roots, cwd);
|
||||
let protect_missing_dot_codex = AbsolutePathBuf::from_absolute_path(cwd)
|
||||
.ok()
|
||||
.is_some_and(|cwd| normalize_effective_absolute_path(cwd) == root);
|
||||
@@ -816,6 +906,7 @@ impl FileSystemSandboxPolicy {
|
||||
}),
|
||||
);
|
||||
WritableRoot {
|
||||
protected_metadata_names,
|
||||
root,
|
||||
// Preserve literal in-root protected paths like `.git` and
|
||||
// `.codex` so downstream sandboxes can still detect and mask
|
||||
@@ -1084,7 +1175,7 @@ fn resolve_candidate_path(path: &Path, cwd: &Path) -> Option<AbsolutePathBuf> {
|
||||
if path.is_absolute() {
|
||||
AbsolutePathBuf::from_absolute_path(path).ok()
|
||||
} else {
|
||||
Some(AbsolutePathBuf::resolve_path_against_base(path, cwd))
|
||||
Some(AbsolutePathBuf::from_absolute_path(cwd).ok()?.join(path))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1277,6 +1368,8 @@ fn sorted_writable_roots(mut roots: Vec<WritableRoot>) -> Vec<WritableRoot> {
|
||||
for root in &mut roots {
|
||||
root.read_only_subpaths =
|
||||
sorted_absolute_paths(std::mem::take(&mut root.read_only_subpaths));
|
||||
root.protected_metadata_names.sort();
|
||||
root.protected_metadata_names.dedup();
|
||||
}
|
||||
roots.sort_by(|left, right| left.root.as_path().cmp(right.root.as_path()));
|
||||
roots
|
||||
@@ -1303,18 +1396,19 @@ fn normalize_effective_absolute_path(path: AbsolutePathBuf) -> AbsolutePathBuf {
|
||||
path
|
||||
}
|
||||
|
||||
fn default_read_only_subpaths_for_writable_root(
|
||||
pub(crate) fn default_read_only_subpaths_for_writable_root(
|
||||
writable_root: &AbsolutePathBuf,
|
||||
protect_missing_dot_codex: bool,
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
|
||||
let top_level_git = writable_root.join(".git");
|
||||
let top_level_git = writable_root.join(PROTECTED_METADATA_GIT_PATH_NAME);
|
||||
// This applies to typical repos (directory .git), worktrees/submodules
|
||||
// (file .git with gitdir pointer), and bare repos when the gitdir is the
|
||||
// writable root itself.
|
||||
let top_level_git_is_file = top_level_git.as_path().is_file();
|
||||
let top_level_git_is_dir = top_level_git.as_path().is_dir();
|
||||
if top_level_git_is_dir || top_level_git_is_file {
|
||||
let should_protect_top_level = top_level_git_is_dir || top_level_git_is_file;
|
||||
if should_protect_top_level {
|
||||
if top_level_git_is_file
|
||||
&& is_git_pointer_file(&top_level_git)
|
||||
&& let Some(gitdir) = resolve_gitdir_from_file(&top_level_git)
|
||||
@@ -1324,7 +1418,7 @@ fn default_read_only_subpaths_for_writable_root(
|
||||
subpaths.push(top_level_git);
|
||||
}
|
||||
|
||||
let top_level_agents = writable_root.join(".agents");
|
||||
let top_level_agents = writable_root.join(PROTECTED_METADATA_AGENTS_PATH_NAME);
|
||||
if top_level_agents.as_path().is_dir() {
|
||||
subpaths.push(top_level_agents);
|
||||
}
|
||||
@@ -1333,7 +1427,7 @@ fn default_read_only_subpaths_for_writable_root(
|
||||
// default. For the workspace root itself, protect it even before the
|
||||
// directory exists so first-time creation still goes through the
|
||||
// protected-path approval flow.
|
||||
let top_level_codex = writable_root.join(".codex");
|
||||
let top_level_codex = writable_root.join(PROTECTED_METADATA_CODEX_PATH_NAME);
|
||||
if protect_missing_dot_codex || top_level_codex.as_path().is_dir() {
|
||||
subpaths.push(top_level_codex);
|
||||
}
|
||||
@@ -1465,8 +1559,105 @@ fn has_explicit_resolved_path_entry(
|
||||
entries.iter().any(|entry| &entry.path == path)
|
||||
}
|
||||
|
||||
fn metadata_path_name(name: &OsStr) -> Option<&'static str> {
|
||||
PROTECTED_METADATA_PATH_NAMES
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|metadata_name| name == OsStr::new(metadata_name))
|
||||
}
|
||||
|
||||
fn metadata_child_of_writable_root(
|
||||
policy: &FileSystemSandboxPolicy,
|
||||
target: &Path,
|
||||
cwd: &Path,
|
||||
) -> Option<(AbsolutePathBuf, &'static str)> {
|
||||
policy
|
||||
.resolved_entries_with_cwd(cwd)
|
||||
.iter()
|
||||
.filter(|entry| entry.access.can_write())
|
||||
.filter_map(|entry| {
|
||||
let relative_path = target.strip_prefix(entry.path.as_path()).ok()?;
|
||||
let first_component = relative_path.components().next()?;
|
||||
let metadata_name = metadata_path_name(first_component.as_os_str())?;
|
||||
Some((entry.path.join(metadata_name), metadata_name))
|
||||
})
|
||||
.next()
|
||||
}
|
||||
|
||||
fn protected_metadata_names_for_writable_root(
|
||||
policy: &FileSystemSandboxPolicy,
|
||||
root: &AbsolutePathBuf,
|
||||
raw_writable_roots: &[&AbsolutePathBuf],
|
||||
cwd: &Path,
|
||||
) -> Vec<String> {
|
||||
let mut protected_names = Vec::new();
|
||||
for metadata_name in PROTECTED_METADATA_PATH_NAMES {
|
||||
let mut metadata_paths = vec![root.join(*metadata_name)];
|
||||
metadata_paths.extend(
|
||||
raw_writable_roots
|
||||
.iter()
|
||||
.map(|raw_root| raw_root.join(*metadata_name)),
|
||||
);
|
||||
|
||||
if metadata_paths
|
||||
.iter()
|
||||
.all(|metadata_path| !policy.can_write_path_with_cwd(metadata_path.as_path(), cwd))
|
||||
{
|
||||
protected_names.push((*metadata_name).to_string());
|
||||
}
|
||||
}
|
||||
protected_names
|
||||
}
|
||||
|
||||
fn protected_metadata_names_need_direct_runtime_enforcement(
|
||||
policy: &FileSystemSandboxPolicy,
|
||||
legacy_policy: &SandboxPolicy,
|
||||
cwd: &Path,
|
||||
) -> bool {
|
||||
let legacy_roots = legacy_policy.get_writable_roots_with_cwd(cwd);
|
||||
policy
|
||||
.get_writable_roots_with_cwd(cwd)
|
||||
.into_iter()
|
||||
.any(|writable_root| {
|
||||
let Some(legacy_root) = legacy_roots
|
||||
.iter()
|
||||
.find(|candidate| candidate.root == writable_root.root)
|
||||
else {
|
||||
return !writable_root.protected_metadata_names.is_empty();
|
||||
};
|
||||
|
||||
writable_root
|
||||
.protected_metadata_names
|
||||
.iter()
|
||||
.any(|metadata_name| {
|
||||
let metadata_path = writable_root.root.join(metadata_name);
|
||||
!legacy_root
|
||||
.read_only_subpaths
|
||||
.iter()
|
||||
.any(|subpath| subpath == &metadata_path)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn has_explicit_write_entry_for_metadata_path(
|
||||
policy: &FileSystemSandboxPolicy,
|
||||
protected_metadata_path: &AbsolutePathBuf,
|
||||
target: &Path,
|
||||
cwd: &Path,
|
||||
) -> bool {
|
||||
policy.resolved_entries_with_cwd(cwd).iter().any(|entry| {
|
||||
entry.access.can_write()
|
||||
&& target.starts_with(entry.path.as_path())
|
||||
&& entry
|
||||
.path
|
||||
.as_path()
|
||||
.starts_with(protected_metadata_path.as_path())
|
||||
})
|
||||
}
|
||||
|
||||
fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
|
||||
path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git"))
|
||||
path.as_path().is_file()
|
||||
&& path.as_path().file_name() == Some(OsStr::new(PROTECTED_METADATA_GIT_PATH_NAME))
|
||||
}
|
||||
|
||||
fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option<AbsolutePathBuf> {
|
||||
@@ -1483,7 +1674,14 @@ fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option<AbsolutePathBuf
|
||||
|
||||
let trimmed = contents.trim();
|
||||
let (_, gitdir_raw) = match trimmed.split_once(':') {
|
||||
Some(parts) => parts,
|
||||
Some((prefix, gitdir_raw)) if prefix.trim() == "gitdir" => (prefix, gitdir_raw),
|
||||
Some(_) => {
|
||||
error!(
|
||||
"Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
|
||||
path = dot_git.as_path().display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
None => {
|
||||
error!(
|
||||
"Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
|
||||
@@ -1696,6 +1894,12 @@ mod tests {
|
||||
.iter()
|
||||
.find(|root| root.root == expected_root)
|
||||
.expect("workspace writable root");
|
||||
assert!(
|
||||
!workspace_root
|
||||
.protected_metadata_names
|
||||
.contains(&".codex".to_string()),
|
||||
"explicit .codex rule should remove the metadata-name protection"
|
||||
);
|
||||
assert!(
|
||||
!workspace_root
|
||||
.read_only_subpaths
|
||||
@@ -1711,32 +1915,46 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_workspace_write_projection_blocks_missing_dot_codex_writes() {
|
||||
fn filesystem_policy_blocks_protected_metadata_path_writes_by_default() {
|
||||
let cwd = TempDir::new().expect("tempdir");
|
||||
let dot_git_config = cwd.path().join(".git").join("config");
|
||||
let dot_agents_config = cwd.path().join(".agents").join("config");
|
||||
let dot_codex_config = cwd.path().join(".codex").join("config.toml");
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
|
||||
let root = AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute cwd");
|
||||
let file_system_policy =
|
||||
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, cwd.path());
|
||||
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: root },
|
||||
access: FileSystemAccessMode::Write,
|
||||
}]);
|
||||
|
||||
assert!(!file_system_policy.can_write_path_with_cwd(&dot_git_config, cwd.path()));
|
||||
assert!(!file_system_policy.can_write_path_with_cwd(&dot_agents_config, cwd.path()));
|
||||
assert!(!file_system_policy.can_write_path_with_cwd(&dot_codex_config, cwd.path()));
|
||||
|
||||
let writable_roots = file_system_policy.get_writable_roots_with_cwd(cwd.path());
|
||||
assert_eq!(writable_roots.len(), 1);
|
||||
assert_eq!(
|
||||
writable_roots[0].protected_metadata_names,
|
||||
vec![
|
||||
".git".to_string(),
|
||||
".agents".to_string(),
|
||||
".codex".to_string(),
|
||||
]
|
||||
);
|
||||
assert!(!writable_roots[0].is_path_writable(&dot_git_config));
|
||||
assert!(!writable_roots[0].is_path_writable(&dot_agents_config));
|
||||
assert!(!writable_roots[0].is_path_writable(&dot_codex_config));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_workspace_write_projection_accepts_relative_cwd() {
|
||||
let relative_cwd = Path::new("workspace");
|
||||
let expected_dot_codex = AbsolutePathBuf::from_absolute_path(
|
||||
let expected_root = AbsolutePathBuf::from_absolute_path(
|
||||
std::env::current_dir()
|
||||
.expect("current dir")
|
||||
.join(relative_cwd)
|
||||
.join(".codex"),
|
||||
.join(relative_cwd),
|
||||
)
|
||||
.expect("absolute dot codex");
|
||||
.expect("absolute root");
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
@@ -1747,51 +1965,62 @@ mod tests {
|
||||
let file_system_policy =
|
||||
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, relative_cwd);
|
||||
|
||||
let mut expected_entries = vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
];
|
||||
expected_entries.extend(PROTECTED_METADATA_PATH_NAMES.iter().map(|name| {
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::project_roots(Some((*name).into())),
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
}
|
||||
}));
|
||||
expected_entries.extend(
|
||||
default_read_only_subpaths_for_writable_root(
|
||||
&expected_root,
|
||||
/*protect_missing_dot_codex*/ true,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|path| FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path },
|
||||
access: FileSystemAccessMode::Read,
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
file_system_policy,
|
||||
FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::project_roots(Some(".git".into())),
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::project_roots(Some(".agents".into())),
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::project_roots(Some(".codex".into())),
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: expected_dot_codex,
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
])
|
||||
FileSystemSandboxPolicy::restricted(expected_entries)
|
||||
);
|
||||
assert_eq!(
|
||||
forbidden_agent_metadata_write(
|
||||
Path::new(".git/config"),
|
||||
relative_cwd,
|
||||
&file_system_policy,
|
||||
),
|
||||
Some(".git")
|
||||
);
|
||||
assert!(
|
||||
!file_system_policy
|
||||
.can_write_path_with_cwd(Path::new(".codex/config.toml"), relative_cwd,)
|
||||
);
|
||||
assert!(
|
||||
!file_system_policy.can_write_path_with_cwd(
|
||||
Path::new(".agents/skills/example/SKILL.md"),
|
||||
relative_cwd,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -2233,8 +2462,9 @@ mod tests {
|
||||
cwd.path(),
|
||||
);
|
||||
assert!(
|
||||
!legacy_workspace_write
|
||||
.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),)
|
||||
legacy_workspace_write
|
||||
.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),),
|
||||
"metadata-name protections must stay in the direct enforcement path even when legacy concrete read-only paths match"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2256,9 +2486,12 @@ mod tests {
|
||||
legacy_order.is_semantically_equivalent_to(&reordered, cwd.path()),
|
||||
"entry order should not affect filesystem semantics"
|
||||
);
|
||||
assert!(
|
||||
!reordered
|
||||
.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path())
|
||||
assert_eq!(
|
||||
legacy_order
|
||||
.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path()),
|
||||
reordered
|
||||
.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path()),
|
||||
"entry order should not affect direct-enforcement classification"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2283,9 +2516,9 @@ mod tests {
|
||||
let legacy_runtime_projection =
|
||||
legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd.path());
|
||||
assert!(
|
||||
!legacy_runtime_projection
|
||||
legacy_runtime_projection
|
||||
.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path()),
|
||||
"true legacy runtime expansion should still classify as legacy-compatible"
|
||||
"metadata-name protections are outside the legacy SandboxPolicy writable-root contract"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
//! between user and agent.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::OsStr;
|
||||
use std::fmt;
|
||||
use std::ops::Mul;
|
||||
use std::path::Path;
|
||||
@@ -84,6 +82,7 @@ pub use crate::permissions::FileSystemSandboxKind;
|
||||
pub use crate::permissions::FileSystemSandboxPolicy;
|
||||
pub use crate::permissions::FileSystemSpecialPath;
|
||||
pub use crate::permissions::NetworkSandboxPolicy;
|
||||
use crate::permissions::default_read_only_subpaths_for_writable_root;
|
||||
pub use crate::request_permissions::RequestPermissionsArgs;
|
||||
pub use crate::request_user_input::RequestUserInputEvent;
|
||||
|
||||
@@ -1087,6 +1086,11 @@ pub struct WritableRoot {
|
||||
|
||||
/// By construction, these subpaths are all under `root`.
|
||||
pub read_only_subpaths: Vec<AbsolutePathBuf>,
|
||||
|
||||
/// Workspace metadata path names that must not be created or replaced under
|
||||
/// `root` unless the policy grants an explicit write rule for that metadata
|
||||
/// path.
|
||||
pub protected_metadata_names: Vec<String>,
|
||||
}
|
||||
|
||||
impl WritableRoot {
|
||||
@@ -1103,8 +1107,26 @@ impl WritableRoot {
|
||||
}
|
||||
}
|
||||
|
||||
if self.path_contains_protected_metadata_name(path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn path_contains_protected_metadata_name(&self, path: &Path) -> bool {
|
||||
let Ok(relative_path) = path.strip_prefix(&self.root) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(first_component) = relative_path.components().next() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
self.protected_metadata_names
|
||||
.iter()
|
||||
.any(|name| first_component.as_os_str() == std::ffi::OsStr::new(name))
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for SandboxPolicy {
|
||||
@@ -1257,6 +1279,7 @@ impl SandboxPolicy {
|
||||
&writable_root,
|
||||
protect_missing_dot_codex,
|
||||
),
|
||||
protected_metadata_names: Vec::new(),
|
||||
root: writable_root,
|
||||
}
|
||||
})
|
||||
@@ -1266,107 +1289,6 @@ impl SandboxPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_read_only_subpaths_for_writable_root(
|
||||
writable_root: &AbsolutePathBuf,
|
||||
protect_missing_dot_codex: bool,
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
|
||||
let top_level_git = writable_root.join(".git");
|
||||
// This applies to typical repos (directory .git), worktrees/submodules
|
||||
// (file .git with gitdir pointer), and bare repos when the gitdir is the
|
||||
// writable root itself.
|
||||
let top_level_git_is_file = top_level_git.as_path().is_file();
|
||||
let top_level_git_is_dir = top_level_git.as_path().is_dir();
|
||||
if top_level_git_is_dir || top_level_git_is_file {
|
||||
if top_level_git_is_file
|
||||
&& is_git_pointer_file(&top_level_git)
|
||||
&& let Some(gitdir) = resolve_gitdir_from_file(&top_level_git)
|
||||
{
|
||||
subpaths.push(gitdir);
|
||||
}
|
||||
subpaths.push(top_level_git);
|
||||
}
|
||||
|
||||
let top_level_agents = writable_root.join(".agents");
|
||||
if top_level_agents.as_path().is_dir() {
|
||||
subpaths.push(top_level_agents);
|
||||
}
|
||||
|
||||
// Keep top-level project metadata under .codex read-only to the agent by
|
||||
// default. For the workspace root itself, protect it even before the
|
||||
// directory exists so first-time creation still goes through the
|
||||
// protected-path approval flow.
|
||||
let top_level_codex = writable_root.join(".codex");
|
||||
if protect_missing_dot_codex || top_level_codex.as_path().is_dir() {
|
||||
subpaths.push(top_level_codex);
|
||||
}
|
||||
|
||||
let mut deduped = Vec::with_capacity(subpaths.len());
|
||||
let mut seen = HashSet::new();
|
||||
for path in subpaths {
|
||||
if seen.insert(path.to_path_buf()) {
|
||||
deduped.push(path);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
|
||||
path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git"))
|
||||
}
|
||||
|
||||
fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option<AbsolutePathBuf> {
|
||||
let contents = match std::fs::read_to_string(dot_git.as_path()) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Failed to read {path} for gitdir pointer: {err}",
|
||||
path = dot_git.as_path().display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let trimmed = contents.trim();
|
||||
let (_, gitdir_raw) = match trimmed.split_once(':') {
|
||||
Some(parts) => parts,
|
||||
None => {
|
||||
error!(
|
||||
"Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
|
||||
path = dot_git.as_path().display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let gitdir_raw = gitdir_raw.trim();
|
||||
if gitdir_raw.is_empty() {
|
||||
error!(
|
||||
"Expected {path} to contain a gitdir pointer, but it was empty.",
|
||||
path = dot_git.as_path().display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let base = match dot_git.as_path().parent() {
|
||||
Some(base) => base,
|
||||
None => {
|
||||
error!(
|
||||
"Unable to resolve parent directory for {path}.",
|
||||
path = dot_git.as_path().display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let gitdir_path = AbsolutePathBuf::resolve_path_against_base(gitdir_raw, base);
|
||||
if !gitdir_path.as_path().exists() {
|
||||
error!(
|
||||
"Resolved gitdir path {path} does not exist.",
|
||||
path = gitdir_path.as_path().display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(gitdir_path)
|
||||
}
|
||||
|
||||
/// Event Queue Entry - events from agent
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Event {
|
||||
|
||||
Reference in New Issue
Block a user