fix: use AbsolutePathBuf for permission profile file roots (#12970)

## Why
`PermissionProfile` should describe filesystem roots as absolute paths
at the type level. Using `PathBuf` in `FileSystemPermissions` made the
shared type too permissive and blurred together three different
deserialization cases:

- skill metadata in `agents/openai.yaml`, where relative paths should
resolve against the skill directory
- app-server API payloads, where callers should have to send absolute
paths
- local tool-call payloads for commands like `shell_command` and
`exec_command`, where `additional_permissions.file_system` may
legitimately be relative to the command `workdir`

This change tightens the shared model without regressing the existing
local command flow.

## What Changed
- changed `protocol::models::FileSystemPermissions` and the app-server
`AdditionalFileSystemPermissions` mirror to use `AbsolutePathBuf`
- wrapped skill metadata deserialization in `AbsolutePathBufGuard`, so
relative permission roots in `agents/openai.yaml` resolve against the
containing skill directory
- kept app-server/API deserialization strict, so relative
`additionalPermissions.fileSystem.*` paths are rejected at the boundary
- restored cwd/workdir-relative deserialization for local tool-call
payloads by parsing `shell`, `shell_command`, and `exec_command`
arguments under an `AbsolutePathBufGuard` rooted at the resolved command
working directory
- simplified runtime additional-permission normalization so it only
canonicalizes and deduplicates absolute roots instead of trying to
recover relative ones later
- updated the app-server schema fixtures, `app-server/README.md`, and
the affected transport/TUI tests to match the final behavior
This commit is contained in:
Michael Bolin
2026-02-27 09:42:52 -08:00
committed by GitHub
parent 8cf5b00aef
commit d09a7535ed
22 changed files with 384 additions and 191 deletions

View File

@@ -521,9 +521,6 @@ pub(crate) mod errors {
SandboxTransformError::SeatbeltUnavailable => CodexErr::UnsupportedOperation(
"seatbelt sandbox is only available on macOS".to_string(),
),
SandboxTransformError::InvalidAdditionalPermissionsPath(path) => {
CodexErr::InvalidRequest(format!("invalid additional_permissions path: {path}"))
}
}
}
}

View File

@@ -92,8 +92,6 @@ pub enum SandboxPreference {
pub(crate) enum SandboxTransformError {
#[error("missing codex-linux-sandbox executable path")]
MissingLinuxSandboxExecutable,
#[error("invalid additional permissions path: {0}")]
InvalidAdditionalPermissionsPath(String),
#[cfg(not(target_os = "macos"))]
#[error("seatbelt sandbox is only available on macOS")]
SeatbeltUnavailable,
@@ -101,19 +99,16 @@ pub(crate) enum SandboxTransformError {
pub(crate) fn normalize_additional_permissions(
additional_permissions: PermissionProfile,
command_cwd: &Path,
) -> Result<PermissionProfile, String> {
let Some(file_system) = additional_permissions.file_system else {
return Ok(PermissionProfile::default());
};
let read = file_system
.read
.map(|paths| normalize_permission_paths(paths, command_cwd, "file_system.read"))
.transpose()?;
.map(|paths| normalize_permission_paths(paths, "file_system.read"));
let write = file_system
.write
.map(|paths| normalize_permission_paths(paths, command_cwd, "file_system.write"))
.transpose()?;
.map(|paths| normalize_permission_paths(paths, "file_system.write"));
Ok(PermissionProfile {
file_system: Some(FileSystemPermissions { read, write }),
..Default::default()
@@ -121,48 +116,25 @@ pub(crate) fn normalize_additional_permissions(
}
fn normalize_permission_paths(
paths: Vec<PathBuf>,
command_cwd: &Path,
permission_kind: &str,
) -> Result<Vec<PathBuf>, String> {
paths: Vec<AbsolutePathBuf>,
_permission_kind: &str,
) -> Vec<AbsolutePathBuf> {
let mut out = Vec::with_capacity(paths.len());
let mut seen = HashSet::new();
for path in paths {
if path.as_os_str().is_empty() {
return Err(format!("{permission_kind} contains an empty path"));
}
let resolved = if path.is_absolute() {
AbsolutePathBuf::from_absolute_path(path.clone()).map_err(|err| {
format!(
"{permission_kind} path `{}` is invalid: {err}",
path.display()
)
})?
} else {
AbsolutePathBuf::resolve_path_against_base(&path, command_cwd).map_err(|err| {
format!(
"{permission_kind} path `{}` cannot be resolved against cwd `{}`: {err}",
path.display(),
command_cwd.display()
)
})?
};
let canonicalized = resolved
let canonicalized = path
.as_path()
.canonicalize()
.ok()
.and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok())
.unwrap_or(resolved);
let canonicalized = canonicalized.to_path_buf();
.unwrap_or(path);
if seen.insert(canonicalized.clone()) {
out.push(canonicalized);
}
}
Ok(out)
out
}
fn dedup_absolute_paths(paths: Vec<AbsolutePathBuf>) -> Vec<AbsolutePathBuf> {
@@ -178,37 +150,23 @@ fn dedup_absolute_paths(paths: Vec<AbsolutePathBuf>) -> Vec<AbsolutePathBuf> {
fn additional_permission_roots(
additional_permissions: &PermissionProfile,
) -> Result<(Vec<AbsolutePathBuf>, Vec<AbsolutePathBuf>), SandboxTransformError> {
let to_abs = |paths: &[PathBuf]| {
let mut out = Vec::with_capacity(paths.len());
for path in paths {
let absolute = AbsolutePathBuf::from_absolute_path(path.clone()).map_err(|err| {
SandboxTransformError::InvalidAdditionalPermissionsPath(format!(
"`{}`: {err}",
path.display()
))
})?;
out.push(absolute);
}
Ok(dedup_absolute_paths(out))
};
Ok((
to_abs(
) -> (Vec<AbsolutePathBuf>, Vec<AbsolutePathBuf>) {
(
dedup_absolute_paths(
additional_permissions
.file_system
.as_ref()
.and_then(|file_system| file_system.read.as_deref())
.and_then(|file_system| file_system.read.clone())
.unwrap_or_default(),
)?,
to_abs(
),
dedup_absolute_paths(
additional_permissions
.file_system
.as_ref()
.and_then(|file_system| file_system.write.as_deref())
.and_then(|file_system| file_system.write.clone())
.unwrap_or_default(),
)?,
))
),
)
}
fn merge_read_only_access_with_additional_reads(
@@ -239,7 +197,7 @@ fn sandbox_policy_with_additional_permissions(
return Ok(sandbox_policy.clone());
}
let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions)?;
let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions);
let policy = match sandbox_policy {
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {

View File

@@ -17,6 +17,7 @@ use crate::skills::system::system_cache_root_dir;
use codex_app_server_protocol::ConfigLayerSource;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use dirs::home_dir;
use dunce::canonicalize as canonicalize_path;
use serde::Deserialize;
@@ -573,15 +574,18 @@ fn load_skill_metadata(skill_path: &Path) -> LoadedSkillMetadata {
}
};
let parsed: SkillMetadataFile = match serde_yaml::from_str(&contents) {
Ok(parsed) => parsed,
Err(error) => {
tracing::warn!(
"ignoring {path}: invalid {label}: {error}",
path = metadata_path.display(),
label = SKILLS_METADATA_FILENAME
);
return LoadedSkillMetadata::default();
let parsed: SkillMetadataFile = {
let _guard = AbsolutePathBufGuard::new(skill_dir);
match serde_yaml::from_str(&contents) {
Ok(parsed) => parsed,
Err(error) => {
tracing::warn!(
"ignoring {path}: invalid {label}: {error}",
path = metadata_path.display(),
label = SKILLS_METADATA_FILENAME
);
return LoadedSkillMetadata::default();
}
}
};
@@ -1376,8 +1380,14 @@ permissions:
Some(PermissionProfile {
network: Some(true),
file_system: Some(FileSystemPermissions {
read: Some(vec![PathBuf::from("./data")]),
write: Some(vec![PathBuf::from("./output")]),
read: Some(vec![
AbsolutePathBuf::try_from(normalized(skill_dir.join("data").as_path()))
.expect("absolute data path"),
]),
write: Some(vec![
AbsolutePathBuf::try_from(normalized(skill_dir.join("output").as_path()))
.expect("absolute output path"),
]),
}),
macos: None,
})

View File

@@ -1,7 +1,5 @@
use std::collections::HashSet;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
#[cfg(target_os = "macos")]
use codex_protocol::models::MacOsAutomationValue;
@@ -11,7 +9,6 @@ use codex_protocol::models::MacOsPreferencesValue;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use dirs::home_dir;
use dunce::canonicalize as canonicalize_path;
use tracing::warn;
@@ -23,7 +20,7 @@ use crate::protocol::ReadOnlyAccess;
use crate::protocol::SandboxPolicy;
pub(crate) fn compile_permission_profile(
skill_dir: &Path,
_skill_dir: &Path,
permissions: Option<PermissionProfile>,
) -> Option<Permissions> {
let PermissionProfile {
@@ -33,12 +30,10 @@ pub(crate) fn compile_permission_profile(
} = permissions?;
let file_system = file_system.unwrap_or_default();
let fs_read = normalize_permission_paths(
skill_dir,
file_system.read.as_deref().unwrap_or_default(),
"permissions.file_system.read",
);
let fs_write = normalize_permission_paths(
skill_dir,
file_system.write.as_deref().unwrap_or_default(),
"permissions.file_system.write",
);
@@ -83,16 +78,12 @@ pub(crate) fn compile_permission_profile(
})
}
fn normalize_permission_paths(
skill_dir: &Path,
values: &[PathBuf],
field: &str,
) -> Vec<AbsolutePathBuf> {
fn normalize_permission_paths(values: &[AbsolutePathBuf], field: &str) -> Vec<AbsolutePathBuf> {
let mut paths = Vec::new();
let mut seen = HashSet::new();
for value in values {
let Some(path) = normalize_permission_path(skill_dir, value, field) else {
let Some(path) = normalize_permission_path(value, field) else {
continue;
};
if seen.insert(path.clone()) {
@@ -103,26 +94,8 @@ fn normalize_permission_paths(
paths
}
fn normalize_permission_path(
skill_dir: &Path,
value: &Path,
field: &str,
) -> Option<AbsolutePathBuf> {
let value = value.to_string_lossy();
let trimmed = value.trim();
if trimmed.is_empty() {
warn!("ignoring {field}: value is empty");
return None;
}
let expanded = expand_home(trimmed);
let absolute = if expanded.is_absolute() {
expanded
} else {
skill_dir.join(expanded)
};
let normalized = normalize_lexically(&absolute);
let canonicalized = canonicalize_path(&normalized).unwrap_or(normalized);
fn normalize_permission_path(value: &AbsolutePathBuf, field: &str) -> Option<AbsolutePathBuf> {
let canonicalized = canonicalize_path(value.as_path()).unwrap_or_else(|_| value.to_path_buf());
match AbsolutePathBuf::from_absolute_path(&canonicalized) {
Ok(path) => Some(path),
Err(error) => {
@@ -132,21 +105,6 @@ fn normalize_permission_path(
}
}
fn expand_home(path: &str) -> PathBuf {
if path == "~" {
if let Some(home) = home_dir() {
return home;
}
return PathBuf::from(path);
}
if let Some(rest) = path.strip_prefix("~/")
&& let Some(home) = home_dir()
{
return home.join(rest);
}
PathBuf::from(path)
}
#[cfg(target_os = "macos")]
fn build_macos_seatbelt_profile_extensions(
permissions: &MacOsPermissions,
@@ -233,22 +191,6 @@ fn build_macos_seatbelt_profile_extensions(
None
}
fn normalize_lexically(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
Component::RootDir | Component::Prefix(_) | Component::Normal(_) => {
normalized.push(component.as_os_str());
}
}
}
normalized
}
#[cfg(test)]
mod tests {
use super::compile_permission_profile;
@@ -269,7 +211,11 @@ mod tests {
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::PathBuf;
use std::path::Path;
fn absolute_path(path: &Path) -> AbsolutePathBuf {
AbsolutePathBuf::try_from(path).expect("absolute path")
}
#[test]
fn compile_permission_profile_normalizes_paths() {
@@ -285,11 +231,11 @@ mod tests {
network: Some(true),
file_system: Some(FileSystemPermissions {
read: Some(vec![
PathBuf::from("./data"),
PathBuf::from("./data"),
PathBuf::from("scripts/../data"),
absolute_path(&skill_dir.join("data")),
absolute_path(&skill_dir.join("data")),
absolute_path(&skill_dir.join("scripts/../data")),
]),
write: Some(vec![PathBuf::from("./output")]),
write: Some(vec![absolute_path(&skill_dir.join("output"))]),
}),
..Default::default()
}),
@@ -389,7 +335,7 @@ mod tests {
Some(PermissionProfile {
network: Some(true),
file_system: Some(FileSystemPermissions {
read: Some(vec![PathBuf::from("./data")]),
read: Some(vec![absolute_path(&skill_dir.join("data"))]),
write: Some(Vec::new()),
}),
..Default::default()

View File

@@ -16,9 +16,12 @@ mod test_sync;
pub(crate) mod unified_exec;
mod view_image;
use codex_utils_absolute_path::AbsolutePathBufGuard;
pub use plan::PLAN_TOOL;
use serde::Deserialize;
use serde_json::Value;
use std::path::Path;
use std::path::PathBuf;
use crate::function_tool::FunctionCallError;
use crate::sandboxing::SandboxPermissions;
@@ -56,6 +59,33 @@ where
})
}
fn parse_arguments_with_base_path<T>(
arguments: &str,
base_path: &Path,
) -> Result<T, FunctionCallError>
where
T: for<'de> Deserialize<'de>,
{
let _guard = AbsolutePathBufGuard::new(base_path);
parse_arguments(arguments)
}
fn resolve_workdir_base_path(
arguments: &str,
default_cwd: &Path,
) -> Result<PathBuf, FunctionCallError> {
let arguments: Value = parse_arguments(arguments)?;
Ok(arguments
.get("workdir")
.and_then(Value::as_str)
.filter(|workdir| !workdir.is_empty())
.map(PathBuf::from)
.map_or_else(
|| default_cwd.to_path_buf(),
|workdir| crate::util::resolve_path(default_cwd, &workdir),
))
}
/// Validates feature/policy constraints for `with_additional_permissions` and
/// returns normalized absolute paths. Errors if paths are invalid.
pub(super) fn normalize_and_validate_additional_permissions(
@@ -63,7 +93,7 @@ pub(super) fn normalize_and_validate_additional_permissions(
approval_policy: AskForApproval,
sandbox_permissions: SandboxPermissions,
additional_permissions: Option<PermissionProfile>,
cwd: &Path,
_cwd: &Path,
) -> Result<Option<PermissionProfile>, String> {
let uses_additional_permissions = matches!(
sandbox_permissions,
@@ -91,7 +121,7 @@ pub(super) fn normalize_and_validate_additional_permissions(
.to_string(),
);
};
let normalized = normalize_additional_permissions(additional_permissions, cwd)?;
let normalized = normalize_additional_permissions(additional_permissions)?;
if normalized.is_empty() {
return Err(
"`additional_permissions` must include at least one path in `file_system.read` or `file_system.write`"

View File

@@ -22,7 +22,8 @@ use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx;
use crate::tools::handlers::apply_patch::intercept_apply_patch;
use crate::tools::handlers::normalize_and_validate_additional_permissions;
use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::parse_arguments_with_base_path;
use crate::tools::handlers::resolve_workdir_base_path;
use crate::tools::orchestrator::ToolOrchestrator;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
@@ -176,7 +177,9 @@ impl ToolHandler for ShellHandler {
match payload {
ToolPayload::Function { arguments } => {
let params: ShellToolCallParams = parse_arguments(&arguments)?;
let cwd = resolve_workdir_base_path(&arguments, turn.cwd.as_path())?;
let params: ShellToolCallParams =
parse_arguments_with_base_path(&arguments, cwd.as_path())?;
let prefix_rule = params.prefix_rule.clone();
let exec_params =
Self::to_exec_params(&params, turn.as_ref(), session.conversation_id);
@@ -266,7 +269,9 @@ impl ToolHandler for ShellCommandHandler {
)));
};
let params: ShellCommandToolCallParams = parse_arguments(&arguments)?;
let cwd = resolve_workdir_base_path(&arguments, turn.cwd.as_path())?;
let params: ShellCommandToolCallParams =
parse_arguments_with_base_path(&arguments, cwd.as_path())?;
maybe_emit_implicit_skill_invocation(
session.as_ref(),
turn.as_ref(),

View File

@@ -13,6 +13,8 @@ use crate::tools::context::ToolPayload;
use crate::tools::handlers::apply_patch::intercept_apply_patch;
use crate::tools::handlers::normalize_and_validate_additional_permissions;
use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::parse_arguments_with_base_path;
use crate::tools::handlers::resolve_workdir_base_path;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use crate::unified_exec::ExecCommandRequest;
@@ -136,7 +138,9 @@ impl ToolHandler for UnifiedExecHandler {
let response = match tool_name.as_str() {
"exec_command" => {
let args: ExecCommandArgs = parse_arguments(&arguments)?;
let cwd = resolve_workdir_base_path(&arguments, context.turn.cwd.as_path())?;
let args: ExecCommandArgs =
parse_arguments_with_base_path(&arguments, cwd.as_path())?;
maybe_emit_implicit_skill_invocation(
session.as_ref(),
turn.as_ref(),
@@ -183,7 +187,7 @@ impl ToolHandler for UnifiedExecHandler {
let workdir = workdir.filter(|value| !value.is_empty());
let workdir = workdir.map(|dir| context.turn.resolve_path(Some(dir)));
let cwd = workdir.clone().unwrap_or_else(|| context.turn.cwd.clone());
let cwd = workdir.clone().unwrap_or(cwd);
let normalized_additional_permissions =
match normalize_and_validate_additional_permissions(
request_permission_enabled,
@@ -336,8 +340,15 @@ fn format_response(response: &UnifiedExecResponse) -> String {
mod tests {
use super::*;
use crate::shell::default_user_shell;
use crate::tools::handlers::parse_arguments_with_base_path;
use crate::tools::handlers::resolve_workdir_base_path;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::fs;
use std::sync::Arc;
use tempfile::tempdir;
#[test]
fn test_get_command_uses_default_shell_when_unspecified() -> anyhow::Result<()> {
@@ -420,4 +431,37 @@ mod tests {
);
Ok(())
}
#[test]
fn exec_command_args_resolve_relative_additional_permissions_against_workdir()
-> anyhow::Result<()> {
let cwd = tempdir()?;
let workdir = cwd.path().join("nested");
fs::create_dir_all(&workdir)?;
let expected_write = workdir.join("relative-write.txt");
let json = r#"{
"cmd": "echo hello",
"workdir": "nested",
"additional_permissions": {
"file_system": {
"write": ["./relative-write.txt"]
}
}
}"#;
let base_path = resolve_workdir_base_path(json, cwd.path())?;
let args: ExecCommandArgs = parse_arguments_with_base_path(json, base_path.as_path())?;
assert_eq!(
args.additional_permissions,
Some(PermissionProfile {
file_system: Some(FileSystemPermissions {
read: None,
write: Some(vec![AbsolutePathBuf::try_from(expected_write)?]),
}),
..Default::default()
})
);
Ok(())
}
}

View File

@@ -36,7 +36,6 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
#[cfg(target_os = "macos")]
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
#[test]
@@ -152,7 +151,9 @@ fn shell_request_escalation_execution_is_explicit() {
let requested_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: None,
write: Some(vec![PathBuf::from("./output")]),
write: Some(vec![
AbsolutePathBuf::from_absolute_path("/tmp/output").unwrap(),
]),
}),
..Default::default()
};