Allow hosts to restrict a thread's root-only capabilities (#58984)

## Why

Hosts need to apply subagent-style capability restrictions without changing a thread's session attribution, agent lineage, or permission profile.

## What changed

Add the public `RestrictRootCapabilities` marker to thread startup extension data. Capture the restriction once at startup and apply it alongside the restrictions native subagents already receive. Hosts must supply the marker again when cold-resuming a restricted thread.

Use the restriction to gate user-input and plugin-install requests, async user messaging, legacy MCP tool approvals, and Full Access MCP form input. Exclude inherited user messages from Guardian's retained authorization context for restricted threads, including after cold resume.

## Testing

Add coverage for host-restricted tool registration, MCP approval rejection, Full Access form-input handling, and inherited instruction authorization after cold resume. Update handler tests to exercise the shared restriction flag.

GitOrigin-RevId: 4d6d7102a20dc747431c7511a74a204c773e8420
This commit is contained in:
rhan-oai
2026-11-02 21:09:37 +00:00
committed by copyberry
parent 6c984676b0
commit 40cf168567
24 changed files with 266 additions and 123 deletions
@@ -114,7 +114,7 @@ impl RequestPluginInstallHandler {
}
};
if turn.session_source.is_non_root_agent() {
if turn.root_capabilities_restricted {
return Err(FunctionCallError::RespondToModel(
"request_plugin_install can only be used by the root thread".to_string(),
));
@@ -16,9 +16,6 @@ use codex_config::types::ToolSuggestDiscoverableType;
use codex_core_plugins::PluginInstallRequest;
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
use codex_login::test_support::auth_manager_from_optional_auth;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_rmcp_client::ElicitationResponse;
use codex_tools::DiscoverablePluginInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -33,15 +30,9 @@ use tokio::sync::Mutex;
#[test_case(ToolSuggestPresentation::ListTool; "legacy install")]
#[test_case(ToolSuggestPresentation::RecommendationContext; "recommended plugin")]
#[tokio::test]
async fn request_plugin_install_rejects_subagent_threads(presentation: ToolSuggestPresentation) {
async fn request_plugin_install_rejects_restricted_threads(presentation: ToolSuggestPresentation) {
let (session, mut turn) = make_session_and_context().await;
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: ThreadId::new(),
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
});
turn.root_capabilities_restricted = true;
let turn = Arc::new(turn);
let arguments = match presentation {
ToolSuggestPresentation::ListTool => json!({
@@ -75,7 +66,7 @@ async fn request_plugin_install_rejects_subagent_threads(presentation: ToolSugge
.await;
let Err(err) = result else {
panic!("subagent request_plugin_install should fail");
panic!("restricted request_plugin_install should fail");
};
assert_eq!(
err,
@@ -64,7 +64,7 @@ impl RequestUserInputHandler {
}
};
if turn.session_source.is_non_root_agent() {
if turn.root_capabilities_restricted {
return Err(FunctionCallError::RespondToModel(
"request_user_input can only be used by the root thread".to_string(),
));
@@ -11,10 +11,7 @@ use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_guardian_context::RenderedVerifiedAnswers;
use codex_protocol::ThreadId;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputResponse;
use pretty_assertions::assert_eq;
@@ -25,15 +22,9 @@ use test_case::test_case;
use tokio::sync::Mutex;
#[tokio::test]
async fn multi_agent_v2_request_user_input_rejects_subagent_threads() {
async fn request_user_input_rejects_restricted_threads() {
let (session, mut turn) = make_session_and_context().await;
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: ThreadId::new(),
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
});
turn.root_capabilities_restricted = true;
let turn = Arc::new(turn);
let result = RequestUserInputHandler {
@@ -74,7 +65,7 @@ async fn multi_agent_v2_request_user_input_rejects_subagent_threads() {
.await;
let Err(err) = result else {
panic!("sub-agent request_user_input should fail");
panic!("restricted request_user_input should fail");
};
assert_eq!(
err,
+2 -2
View File
@@ -1324,7 +1324,7 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, registry: &mut Tool
}
if turn_context.config.experimental_request_user_input_enabled
&& !turn_context.session_source.is_non_root_agent()
&& !turn_context.root_capabilities_restricted
&& context
.model_info
.experimental_supported_tools
@@ -1351,7 +1351,7 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, registry: &mut Tool
);
}
if !turn_context.session_source.is_non_root_agent()
if !turn_context.root_capabilities_restricted
&& (features.enabled(Feature::SendMessageToUserAsync)
|| context
.model_info