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

@@ -13,6 +13,7 @@ use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
@@ -31,6 +32,11 @@ use regex_lite::Regex;
use serde_json::Value;
use serde_json::json;
use std::fs;
use std::path::Path;
fn absolute_path(path: &Path) -> AbsolutePathBuf {
AbsolutePathBuf::try_from(path).expect("absolute path")
}
struct CommandResult {
exit_code: Option<i64>,
@@ -91,6 +97,24 @@ fn shell_event_with_request_permissions(
Ok(ev_function_call(call_id, "shell_command", &args_str))
}
#[cfg(target_os = "macos")]
fn shell_event_with_raw_request_permissions(
call_id: &str,
command: &str,
workdir: Option<&str>,
additional_permissions: Value,
) -> Result<Value> {
let args = json!({
"command": command,
"workdir": workdir,
"timeout_ms": 1_000_u64,
"sandbox_permissions": SandboxPermissions::WithAdditionalPermissions,
"additional_permissions": additional_permissions,
});
let args_str = serde_json::to_string(&args)?;
Ok(ev_function_call(call_id, "shell_command", &args_str))
}
async fn submit_turn(
test: &TestCodex,
prompt: &str,
@@ -187,7 +211,7 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res
let requested_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![]),
write: Some(vec![requested_write.clone()]),
write: Some(vec![absolute_path(&requested_write)]),
}),
..Default::default()
};
@@ -241,6 +265,98 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res
Ok(())
}
#[tokio::test(flavor = "current_thread")]
#[cfg(target_os = "macos")]
async fn relative_additional_permissions_resolve_against_tool_workdir() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
let server = start_mock_server().await;
let approval_policy = AskForApproval::OnRequest;
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let sandbox_policy_for_config = sandbox_policy.clone();
let mut builder = test_codex().with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config);
config.features.enable(Feature::RequestPermissions);
});
let test = builder.build(&server).await?;
let nested_dir = test.workspace_path("nested");
fs::create_dir_all(&nested_dir)?;
let requested_write = nested_dir.join("relative-write.txt");
let _ = fs::remove_file(&requested_write);
let call_id = "request_permissions_relative_workdir";
let command = "touch relative-write.txt";
let expected_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: None,
write: Some(vec![absolute_path(&requested_write)]),
}),
..Default::default()
};
let event = shell_event_with_raw_request_permissions(
call_id,
command,
Some("nested"),
json!({
"file_system": {
"write": ["./relative-write.txt"],
},
}),
)?;
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-relative-1"),
event,
ev_completed("resp-relative-1"),
]),
)
.await;
let results = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-relative-1", "done"),
ev_completed("resp-relative-2"),
]),
)
.await;
submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?;
let approval = expect_exec_approval(&test, command).await;
assert_eq!(
approval.additional_permissions,
Some(expected_permissions.clone())
);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::Approved,
})
.await?;
wait_for_completion(&test).await;
let result = parse_result(&results.single_request().function_call_output(call_id));
assert!(
result.exit_code.is_none() || result.exit_code == Some(0),
"unexpected exit code/output: {:?} {}",
result.exit_code,
result.stdout
);
assert!(
requested_write.exists(),
"touch command should create requested path"
);
Ok(())
}
#[tokio::test(flavor = "current_thread")]
#[cfg(target_os = "macos")]
async fn read_only_with_additional_permissions_widens_to_unrequested_cwd_write() -> Result<()> {
@@ -272,7 +388,7 @@ async fn read_only_with_additional_permissions_widens_to_unrequested_cwd_write()
let requested_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![]),
write: Some(vec![requested_write.clone()]),
write: Some(vec![absolute_path(&requested_write)]),
}),
..Default::default()
};
@@ -363,7 +479,7 @@ async fn read_only_with_additional_permissions_widens_to_unrequested_tmp_write()
let requested_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![]),
write: Some(vec![requested_write.clone()]),
write: Some(vec![absolute_path(&requested_write)]),
}),
..Default::default()
};
@@ -454,14 +570,16 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() ->
let requested_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![]),
write: Some(vec![outside_dir.path().to_path_buf()]),
write: Some(vec![absolute_path(outside_dir.path())]),
}),
..Default::default()
};
let normalized_requested_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![]),
write: Some(vec![outside_dir.path().canonicalize()?]),
write: Some(vec![AbsolutePathBuf::try_from(
outside_dir.path().canonicalize()?,
)?]),
}),
..Default::default()
};
@@ -548,14 +666,16 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul
let requested_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![]),
write: Some(vec![outside_dir.path().to_path_buf()]),
write: Some(vec![absolute_path(outside_dir.path())]),
}),
..Default::default()
};
let normalized_requested_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![]),
write: Some(vec![outside_dir.path().canonicalize()?]),
write: Some(vec![AbsolutePathBuf::try_from(
outside_dir.path().canonicalize()?,
)?]),
}),
..Default::default()
};