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 authored and GitHub committed 2026-02-27 17:42:52 +00:00
1 parent 8cf5b00aef
commit d09a7535ed
22 files changed
+384 -191

No files matched your search

+18 -60
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 { .. } => {