permissions: expose active profile metadata

This commit is contained in:
Michael Bolin
2026-04-29 00:40:44 -07:00
parent e6db1a9442
commit 2456dc92cd
68 changed files with 2094 additions and 1631 deletions
+1
View File
@@ -145,6 +145,7 @@ Example with notification opt-out:
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. For permissions, prefer `permissionProfile`; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissionProfile`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Pass `excludeTurns: true` when the client plans to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`.
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read response `permissionProfile` for the exact active runtime permissions and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known.
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
- `thread/loaded/list` — list the thread ids currently loaded in memory.
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
@@ -106,7 +106,8 @@ use codex_app_server_protocol::MockExperimentalMethodParams;
use codex_app_server_protocol::MockExperimentalMethodResponse;
use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::ModelListResponse;
use codex_app_server_protocol::PermissionProfile as ApiPermissionProfile;
use codex_app_server_protocol::PermissionProfileModificationParams;
use codex_app_server_protocol::PermissionProfileSelectionParams;
use codex_app_server_protocol::PluginDetail;
use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::PluginInstallResponse;
@@ -2379,7 +2380,7 @@ impl CodexMessageProcessor {
approval_policy,
approvals_reviewer,
sandbox,
permission_profile,
permissions,
config,
service_name,
base_instructions,
@@ -2393,11 +2394,11 @@ impl CodexMessageProcessor {
environments,
persist_extended_history,
} = params;
if sandbox.is_some() && permission_profile.is_some() {
if sandbox.is_some() && permissions.is_some() {
self.outgoing
.send_error(
request_id,
invalid_request("`permissionProfile` cannot be combined with `sandbox`"),
invalid_request("`permissions` cannot be combined with `sandbox`"),
)
.await;
return;
@@ -2432,7 +2433,7 @@ impl CodexMessageProcessor {
approval_policy,
approvals_reviewer,
sandbox,
permission_profile,
permissions,
base_instructions,
developer_instructions,
personality,
@@ -2491,7 +2492,7 @@ impl CodexMessageProcessor {
/*approval_policy*/ None,
/*approvals_reviewer*/ None,
/*sandbox*/ None,
/*permission_profile*/ None,
/*permissions*/ None,
/*base_instructions*/ None,
/*developer_instructions*/ None,
/*personality*/ None,
@@ -2801,8 +2802,9 @@ impl CodexMessageProcessor {
&config_snapshot.permission_profile,
config_snapshot.cwd.as_path(),
);
let permission_profile =
thread_response_permission_profile(config_snapshot.permission_profile);
let active_permission_profile = thread_response_active_permission_profile(
config_snapshot.active_permission_profile,
);
let response = ThreadStartResponse {
thread: thread.clone(),
@@ -2814,7 +2816,8 @@ impl CodexMessageProcessor {
approval_policy: config_snapshot.approval_policy.into(),
approvals_reviewer: config_snapshot.approvals_reviewer.into(),
sandbox,
permission_profile,
permission_profile: Some(config_snapshot.permission_profile.into()),
active_permission_profile,
reasoning_effort: config_snapshot.reasoning_effort,
};
Ok::<_, JSONRPCErrorError>((response, thread_started_notification(thread)))
@@ -2870,12 +2873,12 @@ impl CodexMessageProcessor {
approval_policy: Option<codex_app_server_protocol::AskForApproval>,
approvals_reviewer: Option<codex_app_server_protocol::ApprovalsReviewer>,
sandbox: Option<SandboxMode>,
permission_profile: Option<ApiPermissionProfile>,
permissions: Option<PermissionProfileSelectionParams>,
base_instructions: Option<String>,
developer_instructions: Option<String>,
personality: Option<Personality>,
) -> ConfigOverrides {
ConfigOverrides {
let mut overrides = ConfigOverrides {
model,
model_provider,
service_tier,
@@ -2885,14 +2888,15 @@ impl CodexMessageProcessor {
approvals_reviewer: approvals_reviewer
.map(codex_app_server_protocol::ApprovalsReviewer::to_core),
sandbox_mode: sandbox.map(SandboxMode::to_core),
permission_profile: permission_profile.map(Into::into),
codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(),
main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(),
base_instructions,
developer_instructions,
personality,
..Default::default()
}
};
apply_permission_profile_selection_to_config_overrides(&mut overrides, permissions);
overrides
}
async fn thread_archive(&self, request_id: ConnectionRequestId, params: ThreadArchiveParams) {
@@ -4174,11 +4178,11 @@ impl CodexMessageProcessor {
return;
}
if params.sandbox.is_some() && params.permission_profile.is_some() {
if params.sandbox.is_some() && params.permissions.is_some() {
self.outgoing
.send_error(
request_id,
invalid_request("`permissionProfile` cannot be combined with `sandbox`"),
invalid_request("`permissions` cannot be combined with `sandbox`"),
)
.await;
return;
@@ -4211,7 +4215,7 @@ impl CodexMessageProcessor {
approval_policy,
approvals_reviewer,
sandbox,
permission_profile,
permissions,
config: mut request_overrides,
base_instructions,
developer_instructions,
@@ -4246,7 +4250,7 @@ impl CodexMessageProcessor {
approval_policy,
approvals_reviewer,
sandbox,
permission_profile,
permissions,
base_instructions,
developer_instructions,
personality,
@@ -4352,8 +4356,9 @@ impl CodexMessageProcessor {
&config_snapshot.permission_profile,
config_snapshot.cwd.as_path(),
);
let permission_profile =
thread_response_permission_profile(config_snapshot.permission_profile.clone());
let active_permission_profile = thread_response_active_permission_profile(
config_snapshot.active_permission_profile,
);
let response = ThreadResumeResponse {
thread,
@@ -4365,7 +4370,8 @@ impl CodexMessageProcessor {
approval_policy: session_configured.approval_policy.into(),
approvals_reviewer: session_configured.approvals_reviewer.into(),
sandbox,
permission_profile,
permission_profile: Some(config_snapshot.permission_profile.into()),
active_permission_profile,
reasoning_effort: session_configured.reasoning_effort,
};
self.analytics_events_client.track_response(
@@ -4772,7 +4778,7 @@ impl CodexMessageProcessor {
approval_policy,
approvals_reviewer,
sandbox,
permission_profile,
permissions,
config: cli_overrides,
base_instructions,
developer_instructions,
@@ -4782,9 +4788,9 @@ impl CodexMessageProcessor {
} = params;
let include_turns = !exclude_turns;
let result = async {
if sandbox.is_some() && permission_profile.is_some() {
if sandbox.is_some() && permissions.is_some() {
return Err(invalid_request(
"`permissionProfile` cannot be combined with `sandbox`",
"`permissions` cannot be combined with `sandbox`",
));
}
@@ -4837,7 +4843,7 @@ impl CodexMessageProcessor {
approval_policy,
approvals_reviewer,
sandbox,
permission_profile,
permissions,
base_instructions,
developer_instructions,
/*personality*/ None,
@@ -4952,8 +4958,9 @@ impl CodexMessageProcessor {
&config_snapshot.permission_profile,
config_snapshot.cwd.as_path(),
);
let permission_profile =
thread_response_permission_profile(config_snapshot.permission_profile);
let active_permission_profile = thread_response_active_permission_profile(
config_snapshot.active_permission_profile,
);
let response = ThreadForkResponse {
thread: thread.clone(),
@@ -4965,7 +4972,8 @@ impl CodexMessageProcessor {
approval_policy: session_configured.approval_policy.into(),
approvals_reviewer: session_configured.approvals_reviewer.into(),
sandbox,
permission_profile,
permission_profile: Some(config_snapshot.permission_profile.into()),
active_permission_profile,
reasoning_effort: session_configured.reasoning_effort,
};
@@ -6492,7 +6500,7 @@ impl CodexMessageProcessor {
|| params.approval_policy.is_some()
|| params.approvals_reviewer.is_some()
|| params.sandbox_policy.is_some()
|| params.permission_profile.is_some()
|| params.permissions.is_some()
|| params.model.is_some()
|| params.service_tier.is_some()
|| params.effort.is_some()
@@ -6500,9 +6508,9 @@ impl CodexMessageProcessor {
|| collaboration_mode.is_some()
|| params.personality.is_some();
if params.sandbox_policy.is_some() && params.permission_profile.is_some() {
if params.sandbox_policy.is_some() && params.permissions.is_some() {
return Err(invalid_request(
"`permissionProfile` cannot be combined with `sandboxPolicy`",
"`permissions` cannot be combined with `sandboxPolicy`",
));
}
@@ -6512,7 +6520,45 @@ impl CodexMessageProcessor {
.approvals_reviewer
.map(codex_app_server_protocol::ApprovalsReviewer::to_core);
let sandbox_policy = params.sandbox_policy.map(|p| p.to_core());
let permission_profile = params.permission_profile.map(Into::into);
let (permission_profile, active_permission_profile) =
if let Some(permissions) = params.permissions {
let snapshot = thread.config_snapshot().await;
let mut overrides = ConfigOverrides {
cwd: cwd.clone(),
codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(),
main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(),
..Default::default()
};
apply_permission_profile_selection_to_config_overrides(
&mut overrides,
Some(permissions),
);
let config = self
.config_manager
.load_for_cwd(
/*request_overrides*/ None,
overrides,
Some(snapshot.cwd.to_path_buf()),
)
.await
.map_err(|err| config_load_error(&err))?;
// Startup config is allowed to fall back when requirements
// disallow a configured profile. An explicit turn request
// is different: reject it before accepting user input.
if let Some(warning) = config.startup_warnings.iter().find(|warning| {
warning.contains("Configured value for `permission_profile` is disallowed")
}) {
return Err(invalid_request(format!(
"invalid turn context override: {warning}"
)));
}
(
Some(config.permissions.permission_profile()),
config.permissions.active_permission_profile(),
)
} else {
(None, None)
};
let model = params.model;
let effort = params.effort.map(Some);
let summary = params.summary;
@@ -6530,6 +6576,7 @@ impl CodexMessageProcessor {
approvals_reviewer,
sandbox_policy: sandbox_policy.clone(),
permission_profile: permission_profile.clone(),
active_permission_profile: active_permission_profile.clone(),
windows_sandbox_level: None,
model: model.clone(),
effort,
@@ -6556,6 +6603,7 @@ impl CodexMessageProcessor {
approvals_reviewer,
sandbox_policy,
permission_profile,
active_permission_profile,
windows_sandbox_level: None,
model,
effort,
@@ -8228,13 +8276,15 @@ async fn handle_pending_thread_resume_request(
approval_policy,
approvals_reviewer,
permission_profile,
active_permission_profile,
cwd,
reasoning_effort,
..
} = pending.config_snapshot;
let instruction_sources = pending.instruction_sources;
let sandbox = thread_response_sandbox_policy(&permission_profile, cwd.as_path());
let permission_profile = thread_response_permission_profile(permission_profile);
let active_permission_profile =
thread_response_active_permission_profile(active_permission_profile);
let response = ThreadResumeResponse {
thread,
@@ -8246,7 +8296,8 @@ async fn handle_pending_thread_resume_request(
approval_policy: approval_policy.into(),
approvals_reviewer: approvals_reviewer.into(),
sandbox,
permission_profile,
permission_profile: Some(permission_profile.into()),
active_permission_profile,
reasoning_effort,
};
let token_usage_thread = pending.include_turns.then(|| response.thread.clone());
@@ -8473,15 +8524,11 @@ fn collect_resume_override_mismatches(
));
}
}
if let Some(requested_permission_profile) = request.permission_profile.as_ref() {
let requested_permission_profile =
codex_protocol::models::PermissionProfile::from(requested_permission_profile.clone());
if requested_permission_profile != config_snapshot.permission_profile {
mismatch_details.push(format!(
"permission_profile requested={requested_permission_profile:?} active={:?}",
config_snapshot.permission_profile
));
}
if request.permissions.is_some() {
mismatch_details.push(format!(
"permissions override was provided and ignored while running; active={:?}",
config_snapshot.active_permission_profile
));
}
if let Some(requested_personality) = request.personality.as_ref()
&& config_snapshot.personality.as_ref() != Some(requested_personality)
@@ -9370,10 +9417,29 @@ fn with_thread_spawn_agent_metadata(
}
}
fn thread_response_permission_profile(
permission_profile: codex_protocol::models::PermissionProfile,
) -> Option<codex_app_server_protocol::PermissionProfile> {
Some(permission_profile.into())
fn thread_response_active_permission_profile(
active_permission_profile: Option<codex_protocol::models::ActivePermissionProfile>,
) -> Option<codex_app_server_protocol::ActivePermissionProfile> {
active_permission_profile.map(Into::into)
}
fn apply_permission_profile_selection_to_config_overrides(
overrides: &mut ConfigOverrides,
permissions: Option<PermissionProfileSelectionParams>,
) {
let Some(PermissionProfileSelectionParams::Profile { id, modifications }) = permissions else {
return;
};
overrides.default_permissions = Some(id);
overrides
.additional_writable_roots
.extend(modifications.unwrap_or_default().into_iter().map(
|modification| match modification {
PermissionProfileModificationParams::AdditionalWritableRoot { path } => {
path.to_path_buf()
}
},
));
}
fn thread_response_sandbox_policy(
@@ -9401,6 +9467,13 @@ fn requested_permissions_trust_project(overrides: &ConfigOverrides, cwd: &Path)
return true;
}
if matches!(
overrides.default_permissions.as_deref(),
Some(":workspace" | ":danger-no-sandbox")
) {
return true;
}
overrides
.permission_profile
.as_ref()
@@ -9917,23 +9990,6 @@ mod tests {
);
}
#[test]
fn thread_response_permission_profile_preserves_enforcement() {
let full_access_profile = codex_protocol::models::PermissionProfile::Disabled;
let external_profile = codex_protocol::models::PermissionProfile::External {
network: codex_protocol::permissions::NetworkSandboxPolicy::Restricted,
};
assert_eq!(
thread_response_permission_profile(external_profile.clone()),
Some(external_profile.into())
);
assert_eq!(
thread_response_permission_profile(full_access_profile.clone()),
Some(full_access_profile.into())
);
}
#[test]
fn requested_permissions_trust_project_uses_permission_profile_intent() {
let cwd = test_path_buf("/tmp/project").abs();
@@ -9978,6 +10034,20 @@ mod tests {
},
cwd.as_path()
));
assert!(requested_permissions_trust_project(
&ConfigOverrides {
default_permissions: Some(":workspace".to_string()),
..Default::default()
},
cwd.as_path()
));
assert!(requested_permissions_trust_project(
&ConfigOverrides {
default_permissions: Some(":danger-no-sandbox".to_string()),
..Default::default()
},
cwd.as_path()
));
assert!(!requested_permissions_trust_project(
&ConfigOverrides {
permission_profile: Some(read_only_profile),
@@ -9985,6 +10055,13 @@ mod tests {
},
cwd.as_path()
));
assert!(!requested_permissions_trust_project(
&ConfigOverrides {
default_permissions: Some(":read-only".to_string()),
..Default::default()
},
cwd.as_path()
));
}
#[test]
@@ -10155,7 +10232,7 @@ mod tests {
approval_policy: None,
approvals_reviewer: None,
sandbox: None,
permission_profile: None,
permissions: None,
config: None,
base_instructions: None,
developer_instructions: None,
@@ -10170,6 +10247,7 @@ mod tests {
approval_policy: codex_protocol::protocol::AskForApproval::OnRequest,
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User,
permission_profile: codex_protocol::models::PermissionProfile::Disabled,
active_permission_profile: None,
cwd,
ephemeral: false,
reasoning_effort: None,
@@ -754,7 +754,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> {
cwd: None,
approval_policy: None,
sandbox_policy: None,
permission_profile: None,
permissions: None,
approvals_reviewer: None,
model: None,
service_tier: None,
@@ -446,7 +446,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
approval_policy: None,
approvals_reviewer: None,
sandbox: None,
permission_profile: None,
permissions: None,
config: None,
service_name: None,
base_instructions: None,
@@ -27,9 +27,6 @@ use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::FileChangeOutputDeltaNotification;
use codex_app_server_protocol::FileChangePatchUpdatedNotification;
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
use codex_app_server_protocol::FileSystemAccessMode;
use codex_app_server_protocol::FileSystemPath;
use codex_app_server_protocol::FileSystemSandboxEntry;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCError;
@@ -38,9 +35,7 @@ use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PatchApplyStatus;
use codex_app_server_protocol::PatchChangeKind;
use codex_app_server_protocol::PermissionProfile;
use codex_app_server_protocol::PermissionProfileFileSystemPermissions;
use codex_app_server_protocol::PermissionProfileNetworkPermissions;
use codex_app_server_protocol::PermissionProfileSelectionParams;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ServerRequestResolvedNotification;
@@ -67,7 +62,6 @@ use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::Settings;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
@@ -675,9 +669,8 @@ async fn turn_start_rejects_combined_oversized_text_input() -> Result<()> {
}
#[tokio::test]
async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() -> Result<()> {
async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() -> Result<()> {
let codex_home = TempDir::new()?;
let disallowed_write_root = TempDir::new()?;
create_config_toml(
codex_home.path(),
"http://localhost/unused",
@@ -704,9 +697,6 @@ async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() ->
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let disallowed_write_root = AbsolutePathBuf::from_absolute_path(disallowed_write_root.path())
.expect("tempdir path should be absolute");
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
@@ -714,17 +704,9 @@ async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() ->
text: "Hello".to_string(),
text_elements: Vec::new(),
}],
permission_profile: Some(PermissionProfile::Managed {
network: PermissionProfileNetworkPermissions { enabled: false },
file_system: PermissionProfileFileSystemPermissions::Restricted {
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: disallowed_write_root,
},
access: FileSystemAccessMode::Write,
}],
glob_scan_max_depth: None,
},
permissions: Some(PermissionProfileSelectionParams::Profile {
id: ":danger-no-sandbox".to_string(),
modifications: None,
}),
..Default::default()
})
@@ -749,7 +731,7 @@ async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() ->
.await;
assert!(
turn_started.is_err(),
"did not expect a turn/started notification after rejected permissionProfile"
"did not expect a turn/started notification after rejected permissions selection"
);
Ok(())
@@ -1831,7 +1813,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
}),
permission_profile: None,
permissions: None,
model: Some("mock-model".to_string()),
effort: Some(ReasoningEffort::Medium),
summary: Some(ReasoningSummary::Auto),
@@ -1867,7 +1849,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
approval_policy: Some(codex_app_server_protocol::AskForApproval::Never),
approvals_reviewer: None,
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess),
permission_profile: None,
permissions: None,
model: Some("mock-model".to_string()),
effort: Some(ReasoningEffort::Medium),
summary: Some(ReasoningSummary::Auto),