mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
Allow model catalogs to customize approval instruction headings (#57581)
## What changed Add optional `request_permissions` and `auto_review_suffix` fields to catalog approval messages. Use them to customize the permission tool heading and introduce automatic review guidance while preserving the bundled instruction bodies and existing policy gates. Accept `request_permissions_tool` as a deserialization alias. Limit headings to 500 UTF-8 bytes on a single line and fall back to bundled text for missing or invalid values. Complete catalog approval messages retain precedence. ## Testing Add coverage for heading validation, serialization aliases, policy gates, fallback behavior, and complete-message precedence. Integration tests verify that model switches append the selected model's headings without rewriting history or repeating unchanged instructions. GitOrigin-RevId: 3311591a464cc086783fe25271a352dd5a229790
This commit is contained in:
@@ -18,7 +18,9 @@ codex_rust_crate(
|
||||
"//codex-rs/windows-sandbox-rs:codex-windows-managed-deny-probe",
|
||||
"//codex-rs/windows-sandbox-rs:codex-windows-sandbox-setup",
|
||||
],
|
||||
integration_compile_data_extra = glob(["assets/**"]),
|
||||
integration_compile_data_extra = glob(["assets/**"]) + [
|
||||
"//codex-rs/prompts:templates/permissions/approval_policy/on_request_rule_request_permission.md",
|
||||
],
|
||||
integration_test_timeout = "long",
|
||||
run_tests_with_wine_exec = True,
|
||||
rustc_env = {
|
||||
|
||||
@@ -264,6 +264,7 @@ fn permissions_state(
|
||||
never: None,
|
||||
unless_trusted: None,
|
||||
granular: None,
|
||||
..Default::default()
|
||||
};
|
||||
let permission_messages = PermissionMessages {
|
||||
danger_full_access: Some("Full access.".to_string()),
|
||||
|
||||
@@ -50,6 +50,7 @@ async fn catalog_permission_message_loaded_from_remote_models_is_sent(
|
||||
never: None,
|
||||
unless_trusted: None,
|
||||
granular: None,
|
||||
..Default::default()
|
||||
}),
|
||||
collaboration_modes: None,
|
||||
auto_review: None,
|
||||
|
||||
@@ -4,7 +4,9 @@ use codex_core::ForkSnapshot;
|
||||
use codex_core::TurnInputRequest;
|
||||
use codex_core::config::Constrained;
|
||||
use codex_exec_server::CreateDirectoryOptions;
|
||||
use codex_features::Feature;
|
||||
use codex_models_manager::model_info::model_info_from_slug;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::openai_models::ApprovalMessages;
|
||||
use codex_protocol::openai_models::ModelMessages;
|
||||
@@ -35,6 +37,9 @@ use std::collections::HashSet;
|
||||
use tempfile::TempDir;
|
||||
use test_case::test_case;
|
||||
|
||||
const REQUEST_TOOL_BODY: &str = "The built-in `request_permissions` tool is available in this session. Invoke it when you need to request additional `network` or `file_system` permissions before later shell-like commands need them. Request only the specific permissions required for the task.";
|
||||
const AUTO_REVIEW_BODY: &str = "`approvals_reviewer` is `auto_review`: Sandbox escalations with require_escalated will be reviewed for compliance with the policy. If a rejection happens, you should proceed only with a materially safer alternative, or inform the user of the risk and send a final message to ask for approval.";
|
||||
|
||||
fn permissions_texts(request: &ResponsesRequest) -> Vec<String> {
|
||||
request
|
||||
.message_input_texts("developer")
|
||||
@@ -87,9 +92,7 @@ fn model_with_approval_messages(
|
||||
approvals: Some(ApprovalMessages {
|
||||
on_request: Some(on_request.to_string()),
|
||||
on_request_auto_review: Some(on_request_auto_review.to_string()),
|
||||
never: None,
|
||||
unless_trusted: None,
|
||||
granular: None,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
@@ -162,6 +165,125 @@ async fn catalog_approval_message_is_sent_in_initial_permissions() -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ApprovalInstructionCase {
|
||||
Present,
|
||||
Absent,
|
||||
Complete,
|
||||
}
|
||||
|
||||
#[test_case(ApprovalInstructionCase::Present; "present_fields_use_model_wording")]
|
||||
#[test_case(ApprovalInstructionCase::Absent; "absent_fields_preserve_bundled_instructions")]
|
||||
#[test_case(ApprovalInstructionCase::Complete; "complete_approval_message_takes_precedence")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn catalog_approval_instruction_parts_are_sent_in_initial_permissions(
|
||||
case: ApprovalInstructionCase,
|
||||
) -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let req = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
||||
)
|
||||
.await;
|
||||
let model_slug = "catalog-approvals-model";
|
||||
let heading =
|
||||
|value: &str| (!matches!(case, ApprovalInstructionCase::Absent)).then(|| value.to_string());
|
||||
let sandbox = PermissionMessages {
|
||||
read_only: Some(String::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let mut model = model_info_from_slug(model_slug);
|
||||
model.use_responses_lite = true;
|
||||
model.model_messages = Some(ModelMessages {
|
||||
approvals: Some(ApprovalMessages {
|
||||
on_request_auto_review: matches!(case, ApprovalInstructionCase::Complete)
|
||||
.then(|| "Complete catalog approval instructions".to_string()),
|
||||
request_permissions: heading("Catalog permission tool"),
|
||||
auto_review_suffix: heading("Catalog automatic review"),
|
||||
..Default::default()
|
||||
}),
|
||||
permissions: Some(sandbox.clone()),
|
||||
..Default::default()
|
||||
});
|
||||
let fallback_slug = "catalog-approvals-fallback-model";
|
||||
let mut fallback_model = model_with_permission_messages(fallback_slug, sandbox);
|
||||
fallback_model.use_responses_lite = true;
|
||||
let mut builder = test_codex()
|
||||
.with_model(model_slug)
|
||||
.with_config(move |config| {
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
||||
config
|
||||
.permissions
|
||||
.set_permission_profile(PermissionProfile::read_only())
|
||||
.expect("read-only permissions should be allowed");
|
||||
config.config_layer_stack = ConfigLayerStack::default();
|
||||
for (feature, value) in [
|
||||
(Feature::ExecPermissionApprovals, true),
|
||||
(Feature::RequestPermissionsTool, true),
|
||||
] {
|
||||
config
|
||||
.features
|
||||
.set_enabled(feature, value)
|
||||
.expect("test feature setting should be allowed");
|
||||
}
|
||||
config.model_catalog = Some(ModelsResponse {
|
||||
models: vec![model, fallback_model],
|
||||
});
|
||||
});
|
||||
let test = builder.build_with_auto_env(&server).await?;
|
||||
submit_text_turn(&test, "hello").await?;
|
||||
|
||||
let request = req.single_request();
|
||||
let bundled_rule = include_str!("../../../prompts/templates/permissions/approval_policy/on_request_rule_request_permission.md").replace("\r\n", "\n");
|
||||
let bundled_approval = format!(
|
||||
"{bundled_rule}\n\n# request_permissions Tool\n\n{REQUEST_TOOL_BODY}\n\n{AUTO_REVIEW_BODY}"
|
||||
);
|
||||
let expected = match case {
|
||||
ApprovalInstructionCase::Complete => "Complete catalog approval instructions".to_string(),
|
||||
ApprovalInstructionCase::Present => {
|
||||
format!(
|
||||
"{bundled_rule}\n\nCatalog permission tool\n\n{REQUEST_TOOL_BODY}\n\nCatalog automatic review\n\n{AUTO_REVIEW_BODY}"
|
||||
)
|
||||
}
|
||||
ApprovalInstructionCase::Absent => bundled_approval.clone(),
|
||||
};
|
||||
let first = format!("<permissions instructions>\n{expected}\n</permissions instructions>");
|
||||
let annotation = (
|
||||
json!("developer"),
|
||||
json!("permissions.instructions"),
|
||||
json!({"provenance": {"type": "harness", "hook": false}}),
|
||||
);
|
||||
assert_eq!(permissions_texts(&request), vec![first.clone()]);
|
||||
assert_eq!(permissions_annotations(&request), vec![annotation.clone()]);
|
||||
|
||||
if matches!(case, ApprovalInstructionCase::Present) {
|
||||
let next = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
||||
)
|
||||
.await;
|
||||
core_test_support::submit_thread_settings(
|
||||
&test.codex,
|
||||
ThreadSettingsOverrides {
|
||||
model: Some(fallback_slug.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
submit_text_turn(&test, "Continue with the fallback model.").await?;
|
||||
let next = next.single_request();
|
||||
let fallback =
|
||||
format!("<permissions instructions>\n{bundled_approval}\n</permissions instructions>");
|
||||
assert!(next.input().starts_with(&request.input()));
|
||||
assert_eq!(permissions_texts(&next), vec![first, fallback]);
|
||||
assert_eq!(permissions_annotations(&next), vec![annotation; 2]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn model_change_appends_new_catalog_approval_message() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -220,11 +342,8 @@ async fn catalog_non_on_request_approval_messages_are_sent_in_initial_permission
|
||||
(
|
||||
AskForApproval::Never,
|
||||
ApprovalMessages {
|
||||
on_request: None,
|
||||
on_request_auto_review: None,
|
||||
never: Some("catalog never approval instructions".to_string()),
|
||||
unless_trusted: None,
|
||||
granular: None,
|
||||
..Default::default()
|
||||
},
|
||||
"catalog never approval instructions",
|
||||
"Approval policy is currently never",
|
||||
@@ -232,11 +351,8 @@ async fn catalog_non_on_request_approval_messages_are_sent_in_initial_permission
|
||||
(
|
||||
AskForApproval::UnlessTrusted,
|
||||
ApprovalMessages {
|
||||
on_request: None,
|
||||
on_request_auto_review: None,
|
||||
never: None,
|
||||
unless_trusted: Some("catalog unless-trusted approval instructions".to_string()),
|
||||
granular: None,
|
||||
..Default::default()
|
||||
},
|
||||
"catalog unless-trusted approval instructions",
|
||||
"`approval_policy` is `unless-trusted`",
|
||||
|
||||
@@ -116,6 +116,9 @@ mod incremental_tools_resume;
|
||||
#[path = "scenarios_model_instructions.rs"]
|
||||
mod model_instructions;
|
||||
|
||||
#[path = "scenarios_catalog_approval_instructions_tests.rs"]
|
||||
mod catalog_approval_instructions;
|
||||
|
||||
#[path = "scenarios_context_management_tools.rs"]
|
||||
mod context_management_tools;
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Catalog approval headings follow model changes without rewriting or repeating context.
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
use super::*;
|
||||
use codex_core::config::Constrained;
|
||||
use codex_models_manager::model_info::model_info_from_slug;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::openai_models::ApprovalMessages;
|
||||
use codex_protocol::openai_models::ModelMessages;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::ThreadSettingsOverrides;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn catalog_approval_headings_follow_selected_model() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let mock = mount_sse_sequence(
|
||||
&server,
|
||||
[
|
||||
(
|
||||
"response-1",
|
||||
"I can inspect the workspace with the current permissions.",
|
||||
),
|
||||
(
|
||||
"response-2",
|
||||
"The selected model's approval instructions are now active.",
|
||||
),
|
||||
("response-3", "The permissions are unchanged."),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(id, text)| sse(vec![ev_assistant_message(id, text), ev_completed(id)]))
|
||||
.collect(),
|
||||
)
|
||||
.await;
|
||||
let first_slug = "catalog-approval-headings-a";
|
||||
let second_slug = "catalog-approval-headings-b";
|
||||
let models = [(first_slug, "Model A"), (second_slug, "Model B")]
|
||||
.into_iter()
|
||||
.map(|(slug, label)| {
|
||||
let mut model = model_info_from_slug(slug);
|
||||
model.use_responses_lite = true;
|
||||
model.model_messages = Some(ModelMessages {
|
||||
approvals: Some(ApprovalMessages {
|
||||
request_permissions: Some(format!("# {label} permission requests")),
|
||||
auto_review_suffix: Some(format!("# {label} automatic review")),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
model
|
||||
})
|
||||
.collect();
|
||||
let test = test_codex()
|
||||
.with_model(first_slug)
|
||||
.with_config(move |config| {
|
||||
configure_scenario_catalog(config);
|
||||
config.workspace_roots = vec![config.cwd.clone()];
|
||||
config.base_instructions = Some("Help with the workspace.".to_string());
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
||||
config
|
||||
.permissions
|
||||
.set_permission_profile(PermissionProfile::read_only())
|
||||
.expect("read-only permissions should be allowed");
|
||||
for feature in [
|
||||
Feature::ExecPermissionApprovals,
|
||||
Feature::RequestPermissionsTool,
|
||||
] {
|
||||
config
|
||||
.features
|
||||
.enable(feature)
|
||||
.expect("enable permission requests");
|
||||
}
|
||||
config.model_catalog = Some(ModelsResponse { models });
|
||||
})
|
||||
.build_with_auto_env(&server)
|
||||
.await?;
|
||||
|
||||
test.submit_text_turn("Inspect the workspace permissions.")
|
||||
.await?;
|
||||
core_test_support::submit_thread_settings(
|
||||
&test.codex,
|
||||
ThreadSettingsOverrides {
|
||||
model: Some(second_slug.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
test.submit_text_turn("Continue with the selected model.")
|
||||
.await?;
|
||||
test.submit_text_turn("Continue without changing permissions.")
|
||||
.await?;
|
||||
|
||||
let requests = mock.requests();
|
||||
assert_eq!(requests.len(), 3);
|
||||
let headings = requests
|
||||
.iter()
|
||||
.map(|request| {
|
||||
request
|
||||
.message_input_texts("developer")
|
||||
.into_iter()
|
||||
.filter(|text| text.contains("<permissions instructions>"))
|
||||
.map(|text| {
|
||||
text.lines()
|
||||
.filter(|line| line.starts_with("# Model "))
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let first_headings = vec![
|
||||
"# Model A permission requests",
|
||||
"# Model A automatic review",
|
||||
];
|
||||
let second_headings = vec![
|
||||
"# Model B permission requests",
|
||||
"# Model B automatic review",
|
||||
];
|
||||
assert_eq!(
|
||||
headings,
|
||||
vec![
|
||||
vec![first_headings.clone()],
|
||||
vec![first_headings.clone(), second_headings.clone()],
|
||||
vec![first_headings, second_headings],
|
||||
]
|
||||
);
|
||||
for pair in requests.windows(2) {
|
||||
let previous = pair[0].input();
|
||||
let next = pair[1].input();
|
||||
assert_eq!(&next[..previous.len()], previous);
|
||||
}
|
||||
insta::assert_snapshot!(context_snapshot::format_request_history_snapshot(
|
||||
"Catalog permission-request and automatic-review headings follow the selected model. The switch appends new developer instructions after the prior reply and before the next user message; an unchanged follow-up preserves the full history without repeating instructions.",
|
||||
&requests,
|
||||
&ContextSnapshotOptions::default().include_request_settings(),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
@@ -138,6 +138,7 @@ async fn protected_model_settings_use_the_proposed_permissions(
|
||||
never: None,
|
||||
unless_trusted: None,
|
||||
granular: None,
|
||||
..Default::default()
|
||||
});
|
||||
messages.permissions = Some(PermissionMessages {
|
||||
danger_full_access: None,
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
---
|
||||
source: core/tests/suite/scenarios_catalog_approval_instructions_tests.rs
|
||||
assertion_line: 138
|
||||
expression: "context_snapshot::format_request_history_snapshot(\"Catalog permission-request and automatic-review headings follow the selected model. The switch appends new developer instructions after the prior reply and before the next user message; an unchanged follow-up preserves the full history without repeating instructions.\",\n&requests, &ContextSnapshotOptions::default().include_request_settings(),)"
|
||||
---
|
||||
Scenario: Catalog permission-request and automatic-review headings follow the selected model. The switch appends new developer instructions after the prior reply and before the next user message; an unchanged follow-up preserves the full history without repeating instructions.
|
||||
|
||||
## Window 1
|
||||
Settings:
|
||||
include: ["reasoning.encrypted_content"]
|
||||
model: "catalog-approval-headings-a"
|
||||
parallel_tool_calls: false
|
||||
prompt_cache_key: "<PROMPT_CACHE_KEY 1>"
|
||||
reasoning: {"context":"all_turns","summary":"auto"}
|
||||
store: false
|
||||
stream: true
|
||||
tool_choice: "auto"
|
||||
-- request 1 (turn) --
|
||||
00:additional_tools/developer (2; hash=23179252be1abe5f):
|
||||
- namespace/functions; hash=9962be1e4c2d492b
|
||||
- function/exec_command
|
||||
- function/write_stdin
|
||||
- function/request_user_input
|
||||
- function/request_permissions
|
||||
- function/view_image
|
||||
- namespace/multi_agent_v1: Tools for spawning and managing sub-agents.; hash=c826e5b07fd2820f
|
||||
- function/close_agent
|
||||
- function/resume_agent
|
||||
- function/send_input
|
||||
- function/spawn_agent
|
||||
- function/wait_agent
|
||||
01:message/developer:
|
||||
Help with the workspace.
|
||||
02:message/developer:
|
||||
<permissions instructions>
|
||||
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The san... permits reading files. Network access is restricted. [hash=be7da4425f89053c]
|
||||
# Permission Requests
|
||||
|
||||
Commands may require user approval before execution. Prefer requesting sandboxed additional permissions instead of asking to run fully outside the sandbox.
|
||||
|
||||
## Preferred request mode
|
||||
|
||||
<OMITTED 29 LINES; ~379 TOKENS; hash=d11e3f29e8628ec8>
|
||||
# Model A permission requests
|
||||
|
||||
The built-in `request_permissions` tool is available in this session. Invoke it when you need to request... only the specific permissions required for the task. [hash=a29255aad0722385]
|
||||
|
||||
# Model A automatic review
|
||||
|
||||
`approvals_reviewer` is `auto_review`: Sandbox escalations with require_escalated will be reviewed for c...he risk and send a final message to ask for approval. [hash=c592d119d24cd2d6]
|
||||
</permissions instructions>
|
||||
03:message/user:
|
||||
<environment_context>
|
||||
<cwd><CWD></cwd>
|
||||
<shell><HOST_SHELL></shell>
|
||||
<current_date><CURRENT_DATE></current_date>
|
||||
<timezone><HOST_TIMEZONE></timezone>
|
||||
<filesystem><workspace_roots><root><CWD></root></workspace_roots><permission_profile type="managed"><f...ntry></file_system></permission_profile></filesystem> [hash=e8c1de60307d0a3d]
|
||||
</environment_context>
|
||||
04:message/user:
|
||||
Inspect the workspace permissions.
|
||||
|
||||
## Window 2 (after request 1)
|
||||
Settings: relative to window 1
|
||||
model: "catalog-approval-headings-b"
|
||||
-- request 2 (turn) --
|
||||
00:additional_tools/developer (2; hash=23179252be1abe5f):
|
||||
- namespace/functions; hash=9962be1e4c2d492b
|
||||
- function/exec_command
|
||||
- function/write_stdin
|
||||
- function/request_user_input
|
||||
- function/request_permissions
|
||||
- function/view_image
|
||||
- namespace/multi_agent_v1: Tools for spawning and managing sub-agents.; hash=c826e5b07fd2820f
|
||||
- function/close_agent
|
||||
- function/resume_agent
|
||||
- function/send_input
|
||||
- function/spawn_agent
|
||||
- function/wait_agent
|
||||
01:message/developer:
|
||||
Help with the workspace.
|
||||
02:message/developer:
|
||||
<permissions instructions>
|
||||
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The san... permits reading files. Network access is restricted. [hash=be7da4425f89053c]
|
||||
# Permission Requests
|
||||
|
||||
Commands may require user approval before execution. Prefer requesting sandboxed additional permissions instead of asking to run fully outside the sandbox.
|
||||
|
||||
## Preferred request mode
|
||||
|
||||
<OMITTED 29 LINES; ~379 TOKENS; hash=d11e3f29e8628ec8>
|
||||
# Model A permission requests
|
||||
|
||||
The built-in `request_permissions` tool is available in this session. Invoke it when you need to request... only the specific permissions required for the task. [hash=a29255aad0722385]
|
||||
|
||||
# Model A automatic review
|
||||
|
||||
`approvals_reviewer` is `auto_review`: Sandbox escalations with require_escalated will be reviewed for c...he risk and send a final message to ask for approval. [hash=c592d119d24cd2d6]
|
||||
</permissions instructions>
|
||||
03:message/user:
|
||||
<environment_context>
|
||||
<cwd><CWD></cwd>
|
||||
<shell><HOST_SHELL></shell>
|
||||
<current_date><CURRENT_DATE></current_date>
|
||||
<timezone><HOST_TIMEZONE></timezone>
|
||||
<filesystem><workspace_roots><root><CWD></root></workspace_roots><permission_profile type="managed"><f...ntry></file_system></permission_profile></filesystem> [hash=e8c1de60307d0a3d]
|
||||
</environment_context>
|
||||
04:message/user:
|
||||
Inspect the workspace permissions.
|
||||
05:message/assistant:
|
||||
I can inspect the workspace with the current permissions.
|
||||
06:message/developer:
|
||||
<model_instructions>
|
||||
The current model is catalog-approval-headings-b.
|
||||
</model_instructions>
|
||||
07:message/developer:
|
||||
<permissions instructions>
|
||||
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The san... permits reading files. Network access is restricted. [hash=be7da4425f89053c]
|
||||
# Permission Requests
|
||||
|
||||
Commands may require user approval before execution. Prefer requesting sandboxed additional permissions instead of asking to run fully outside the sandbox.
|
||||
|
||||
## Preferred request mode
|
||||
|
||||
<OMITTED 29 LINES; ~379 TOKENS; hash=d11e3f29e8628ec8>
|
||||
# Model B permission requests
|
||||
|
||||
The built-in `request_permissions` tool is available in this session. Invoke it when you need to request... only the specific permissions required for the task. [hash=a29255aad0722385]
|
||||
|
||||
# Model B automatic review
|
||||
|
||||
`approvals_reviewer` is `auto_review`: Sandbox escalations with require_escalated will be reviewed for c...he risk and send a final message to ask for approval. [hash=c592d119d24cd2d6]
|
||||
</permissions instructions>
|
||||
08:message/user:
|
||||
Continue with the selected model.
|
||||
-- request 3 (turn) --
|
||||
09:message/assistant:
|
||||
The selected model's approval instructions are now active.
|
||||
10:message/user:
|
||||
Continue without changing permissions.
|
||||
@@ -1104,6 +1104,7 @@ async fn active_model_switch_updates_core_context_from_captured_settings(
|
||||
never: None,
|
||||
unless_trusted: None,
|
||||
granular: None,
|
||||
..Default::default()
|
||||
});
|
||||
messages.token_budget =
|
||||
(initial_model || destination_has_guidance).then(|| ModelTokenBudgetConfig {
|
||||
|
||||
@@ -41,6 +41,7 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() {
|
||||
prompted_categories: Some("Allowed: {{ categories }}".to_string()),
|
||||
rejected_categories: Some("Denied: {{ categories }}".to_string()),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let collaboration_modes = CollaborationModeMessages {
|
||||
default: Some("default instructions".to_string()),
|
||||
@@ -174,6 +175,7 @@ fn personality_none_strips_catalog_instruction_sources_through_the_next_h1() {
|
||||
never: None,
|
||||
unless_trusted: None,
|
||||
granular: None,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
exports_files(
|
||||
["templates/permissions/approval_policy/on_request_rule_request_permission.md"],
|
||||
visibility = ["//codex-rs/core:__pkg__"],
|
||||
)
|
||||
|
||||
codex_rust_crate(
|
||||
name = "prompts",
|
||||
compile_data = glob(["templates/**"]),
|
||||
|
||||
@@ -26,6 +26,7 @@ pub(crate) const DENIED_READS_TEMPLATE: &str = "## Denied filesystem reads\n{{ d
|
||||
/// Resolved approval-policy text; policy and tool selection remain with the consumer.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct ResolvedApprovalMessages<'a> {
|
||||
pub(crate) catalog: Option<&'a ApprovalMessages>,
|
||||
pub(crate) never: ResolvedMessage<'a>,
|
||||
pub(crate) on_request: ResolvedMessage<'a>,
|
||||
pub(crate) on_request_auto_review: ResolvedMessage<'a>,
|
||||
@@ -35,6 +36,7 @@ pub(crate) struct ResolvedApprovalMessages<'a> {
|
||||
impl<'a> ResolvedApprovalMessages<'a> {
|
||||
pub(crate) fn new(messages: Option<&'a ApprovalMessages>) -> Self {
|
||||
Self {
|
||||
catalog: messages,
|
||||
never: ResolvedMessage::new(
|
||||
messages.and_then(|m| m.never.as_deref()),
|
||||
APPROVAL_POLICY_NEVER,
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::model_messages::permissions::READ_ONLY_TEMPLATE;
|
||||
use crate::model_messages::permissions::ResolvedApprovalMessages;
|
||||
use crate::model_messages::permissions::ResolvedPermissionMessages;
|
||||
use crate::model_messages::permissions::WORKSPACE_WRITE_TEMPLATE;
|
||||
use approval_instructions::ApprovalInstructions;
|
||||
use codex_context_fragments::ContextualUserFragment;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
@@ -32,6 +33,7 @@ use std::path::Path;
|
||||
use std::sync::LazyLock;
|
||||
use writable_roots::writable_roots_text;
|
||||
|
||||
mod approval_instructions;
|
||||
mod path_warnings;
|
||||
mod writable_roots;
|
||||
|
||||
@@ -332,30 +334,31 @@ fn approval_text(
|
||||
}
|
||||
ResolvedMessage::Bundled(text) => text,
|
||||
};
|
||||
let instructions = ApprovalInstructions::new(approval_context);
|
||||
let text = match approval_policy {
|
||||
AskForApproval::Never => return base.to_string(),
|
||||
AskForApproval::UnlessTrusted => {
|
||||
if request_permissions_tool_enabled {
|
||||
format!("{base}\n\n{REQUEST_PERMISSIONS_TOOL}")
|
||||
format!("{base}\n\n{}", instructions.tool())
|
||||
} else {
|
||||
base.to_string()
|
||||
}
|
||||
}
|
||||
AskForApproval::OnRequest => {
|
||||
let rule = if exec_permission_approvals_enabled {
|
||||
REQUEST_PERMISSION_RULE
|
||||
let mut sections = vec![if exec_permission_approvals_enabled {
|
||||
REQUEST_PERMISSION_RULE.to_string()
|
||||
} else {
|
||||
base
|
||||
};
|
||||
let mut sections = vec![rule.to_string()];
|
||||
base.to_string()
|
||||
}];
|
||||
if request_permissions_tool_enabled {
|
||||
sections.push(REQUEST_PERMISSIONS_TOOL.to_string());
|
||||
sections.push(instructions.tool());
|
||||
}
|
||||
sections.extend(render_prefixes());
|
||||
sections.join("\n\n")
|
||||
}
|
||||
AskForApproval::Granular(granular_config) => granular_instructions(
|
||||
granular_config,
|
||||
approval_context,
|
||||
render_prefixes(),
|
||||
exec_permission_approvals_enabled,
|
||||
request_permissions_tool_enabled,
|
||||
@@ -363,7 +366,7 @@ fn approval_text(
|
||||
};
|
||||
|
||||
if approval_context.reviewer == ApprovalsReviewer::AutoReview {
|
||||
format!("{text}\n\n{AUTO_REVIEW_SUFFIX}")
|
||||
format!("{text}\n\n{}", instructions.auto_review())
|
||||
} else {
|
||||
text
|
||||
}
|
||||
@@ -398,10 +401,12 @@ fn approved_command_prefixes_text(prefixes: &[Vec<String>], template: &str) -> O
|
||||
|
||||
fn granular_instructions(
|
||||
granular_config: GranularApprovalConfig,
|
||||
approval_context: ApprovalPromptContext<'_>,
|
||||
approved_command_prefixes: Option<String>,
|
||||
exec_permission_approvals_enabled: bool,
|
||||
request_permissions_tool_enabled: bool,
|
||||
) -> String {
|
||||
let instructions = ApprovalInstructions::new(approval_context);
|
||||
let sandbox_approval_prompts_allowed = granular_config.allows_sandbox_approval();
|
||||
let shell_permission_requests_available =
|
||||
exec_permission_approvals_enabled && sandbox_approval_prompts_allowed;
|
||||
@@ -456,7 +461,7 @@ fn granular_instructions(
|
||||
}
|
||||
|
||||
if request_permissions_tool_prompts_allowed {
|
||||
sections.push(REQUEST_PERMISSIONS_TOOL.to_string());
|
||||
sections.push(instructions.tool());
|
||||
}
|
||||
|
||||
sections.extend(approved_command_prefixes);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Selects bounded catalog headings while preserving all permission and review guidance.
|
||||
|
||||
use super::AUTO_REVIEW_SUFFIX;
|
||||
use super::ApprovalPromptContext;
|
||||
use super::PermissionsInstructions;
|
||||
use super::REQUEST_PERMISSIONS_TOOL;
|
||||
use codex_context_fragments::ContextualUserFragment;
|
||||
use codex_protocol::openai_models::ApprovalMessages;
|
||||
|
||||
pub(super) struct ApprovalInstructions<'a>(Option<&'a ApprovalMessages>);
|
||||
|
||||
impl<'a> ApprovalInstructions<'a> {
|
||||
pub(super) fn new(context: ApprovalPromptContext<'a>) -> Self {
|
||||
Self(context.messages.catalog)
|
||||
}
|
||||
|
||||
pub(super) fn tool(&self) -> String {
|
||||
with_heading(
|
||||
self.0.and_then(|m| m.request_permissions.as_deref()),
|
||||
REQUEST_PERMISSIONS_TOOL,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn auto_review(&self) -> String {
|
||||
match self
|
||||
.0
|
||||
.and_then(|m| m.auto_review_suffix.as_deref())
|
||||
.filter(|s| valid_heading(s))
|
||||
{
|
||||
Some(heading) => format!("{heading}\n\n{AUTO_REVIEW_SUFFIX}"),
|
||||
None => AUTO_REVIEW_SUFFIX.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn with_heading(heading: Option<&str>, bundled: &str) -> String {
|
||||
match (
|
||||
heading.filter(|s| valid_heading(s)),
|
||||
bundled.find(['\r', '\n']),
|
||||
) {
|
||||
(Some(heading), Some(end)) => format!("{heading}{}", &bundled[end..]),
|
||||
_ => bundled.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
// One single-line heading can add at most 500 UTF-8 bytes plus its paragraph separator.
|
||||
fn valid_heading(text: &str) -> bool {
|
||||
let (open, close) = PermissionsInstructions::type_markers();
|
||||
text.len() <= 500
|
||||
&& !text.trim().is_empty()
|
||||
&& !text.chars().any(char::is_control)
|
||||
&& !text.contains(['{', '}', '\u{2028}', '\u{2029}'])
|
||||
&& !text.contains(open)
|
||||
&& !text.contains(close)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "approval_instructions_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Verifies catalog heading bounds and preservation of bundled instruction line endings.
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn replacing_heading_preserves_the_bundled_line_endings() {
|
||||
let bundled = "Heading\r\n\r\nRequired instructions.\r\n";
|
||||
assert_eq!(
|
||||
with_heading(Some("Catalog"), bundled),
|
||||
"Catalog\r\n\r\nRequired instructions.\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_bounded_heading_and_rejects_malformed_presentation() {
|
||||
let boundary = "é".repeat(250);
|
||||
assert!(valid_heading(&boundary));
|
||||
let (open, close) = PermissionsInstructions::type_markers();
|
||||
for invalid in [
|
||||
"",
|
||||
" ",
|
||||
&format!("{boundary}a"),
|
||||
open,
|
||||
close,
|
||||
"{{ policy }}",
|
||||
"title\nbody",
|
||||
"title\u{2028}body",
|
||||
] {
|
||||
assert!(!valid_heading(invalid), "accepted {invalid:?}");
|
||||
}
|
||||
}
|
||||
@@ -427,6 +427,7 @@ fn catalog_approval_messages_select_reviewer_variant() {
|
||||
never: Some("never catalog approvals".to_string()),
|
||||
unless_trusted: Some("unless-trusted catalog approvals".to_string()),
|
||||
granular: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for (approval_policy, reviewer, expected) in [
|
||||
@@ -476,6 +477,7 @@ fn empty_catalog_approval_message_suppresses_legacy_approval_section() {
|
||||
never: None,
|
||||
unless_trusted: None,
|
||||
granular: None,
|
||||
..Default::default()
|
||||
};
|
||||
let approved_command_prefixes = vec![vec!["git".to_string(), "pull".to_string()]];
|
||||
let writable_root = "/tmp/repo".to_string();
|
||||
@@ -517,6 +519,7 @@ fn missing_catalog_key_uses_legacy_approval_text() {
|
||||
never: None,
|
||||
unless_trusted: None,
|
||||
granular: None,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
@@ -527,6 +530,7 @@ fn missing_catalog_key_uses_legacy_approval_text() {
|
||||
never: None,
|
||||
unless_trusted: None,
|
||||
granular: None,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
@@ -571,6 +575,7 @@ fn empty_catalog_non_on_request_approval_messages_suppress_legacy_approval_text(
|
||||
never: Some(String::new()),
|
||||
unless_trusted: Some(String::new()),
|
||||
granular: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for approval_policy in [AskForApproval::Never, AskForApproval::UnlessTrusted] {
|
||||
@@ -641,6 +646,79 @@ const ALL_PROMPTED_CATEGORIES: &str =
|
||||
- `skill_approval`
|
||||
- `mcp_elicitations`";
|
||||
|
||||
#[test]
|
||||
fn granular_approval_headings_follow_independent_tool_and_sandbox_gates() {
|
||||
let messages = ApprovalMessages {
|
||||
request_permissions: Some("Catalog permission tool".into()),
|
||||
auto_review_suffix: Some("Catalog reviewer".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let context = ApprovalPromptContext {
|
||||
messages: ResolvedApprovalMessages::new(Some(&messages)),
|
||||
..user_approval_context()
|
||||
};
|
||||
for (sandbox_allowed, tool_allowed) in
|
||||
[(true, false), (false, true), (true, true), (false, false)]
|
||||
{
|
||||
let text = approval_text(
|
||||
AskForApproval::Granular(GranularApprovalConfig {
|
||||
sandbox_approval: sandbox_allowed,
|
||||
request_permissions: tool_allowed,
|
||||
..ALL_APPROVALS_ENABLED
|
||||
}),
|
||||
context,
|
||||
&[],
|
||||
/*exec_permission_approvals_enabled*/ true,
|
||||
/*request_permissions_tool_enabled*/ true,
|
||||
);
|
||||
assert_eq!(text.contains(REQUEST_PERMISSION_RULE), sandbox_allowed);
|
||||
assert_eq!(text.contains("Catalog permission tool"), tool_allowed);
|
||||
assert!(!text.contains("Catalog reviewer"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_heading_fallback_and_policy_selection_are_independent() {
|
||||
let messages = ApprovalMessages {
|
||||
request_permissions: Some("Catalog permission tool".into()),
|
||||
auto_review_suffix: Some("{{ unknown }}".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let bundled = ApprovalPromptContext::new(
|
||||
ApprovalsReviewer::AutoReview,
|
||||
ResolvedModelMessages::bundled(),
|
||||
);
|
||||
let catalog = ApprovalPromptContext {
|
||||
messages: ResolvedApprovalMessages::new(Some(&messages)),
|
||||
..bundled
|
||||
};
|
||||
for policy in [
|
||||
AskForApproval::Never,
|
||||
AskForApproval::UnlessTrusted,
|
||||
AskForApproval::OnRequest,
|
||||
] {
|
||||
for tool_enabled in [false, true] {
|
||||
let render = |context| {
|
||||
approval_text(
|
||||
policy,
|
||||
context,
|
||||
&[],
|
||||
/*exec_permission_approvals_enabled*/ true,
|
||||
tool_enabled,
|
||||
)
|
||||
};
|
||||
let default = render(bundled);
|
||||
let expected = if tool_enabled && policy != AskForApproval::Never {
|
||||
assert!(default.contains("# request_permissions Tool"));
|
||||
default.replace("# request_permissions Tool", "Catalog permission tool")
|
||||
} else {
|
||||
default
|
||||
};
|
||||
assert_eq!(render(catalog), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn granular_policy_lists_prompted_and_rejected_categories_separately() {
|
||||
let text = approval_text(
|
||||
@@ -740,6 +818,7 @@ fn preserves_supplied_path_spellings_and_order() {
|
||||
never: None,
|
||||
unless_trusted: None,
|
||||
granular: None,
|
||||
..Default::default()
|
||||
};
|
||||
let permission_messages = PermissionMessages {
|
||||
danger_full_access: None,
|
||||
|
||||
@@ -769,12 +769,22 @@ pub struct ModelTokenBudgetConfig {
|
||||
pub auto_compact_fallback_buffer_tokens: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
||||
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
|
||||
pub struct ApprovalMessages {
|
||||
pub on_request: Option<String>,
|
||||
pub on_request_auto_review: Option<String>,
|
||||
pub never: Option<String>,
|
||||
pub unless_trusted: Option<String>,
|
||||
/// Optional replacement heading for the request_permissions tool instructions.
|
||||
#[serde(
|
||||
default,
|
||||
alias = "request_permissions_tool",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub request_permissions: Option<String>,
|
||||
/// Optional introduction to the harness-owned automatic escalation review policy.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auto_review_suffix: Option<String>,
|
||||
/// Optional catalog templates for harness-owned granular approval facts.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub granular: Option<GranularApprovalMessages>,
|
||||
@@ -1285,7 +1295,9 @@ mod tests {
|
||||
"instructions_template": null,
|
||||
"approvals": {
|
||||
"on_request": "",
|
||||
"never": ""
|
||||
"never": "",
|
||||
"request_permissions": "",
|
||||
"auto_review_suffix": null
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
@@ -1295,13 +1307,40 @@ mod tests {
|
||||
messages.approvals,
|
||||
Some(ApprovalMessages {
|
||||
on_request: Some(String::new()),
|
||||
request_permissions: Some(String::new()),
|
||||
on_request_auto_review: None,
|
||||
never: Some(String::new()),
|
||||
unless_trusted: None,
|
||||
granular: None,
|
||||
..Default::default()
|
||||
})
|
||||
);
|
||||
|
||||
for field in ["request_permissions", "request_permissions_tool"] {
|
||||
for heading in [
|
||||
serde_json::Value::Null,
|
||||
serde_json::json!(""),
|
||||
serde_json::json!("Permission requests"),
|
||||
] {
|
||||
let approvals: ApprovalMessages =
|
||||
serde_json::from_value(serde_json::json!({ field: heading }))
|
||||
.expect("permission heading should deserialize with either field name");
|
||||
let mut expected = serde_json::json!({
|
||||
"on_request": null,
|
||||
"on_request_auto_review": null,
|
||||
"never": null,
|
||||
"unless_trusted": null,
|
||||
});
|
||||
if !heading.is_null() {
|
||||
expected["request_permissions"] = heading;
|
||||
}
|
||||
assert_eq!(
|
||||
serde_json::to_value(approvals).expect("serialize approvals"),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (granular, expected) in [
|
||||
(serde_json::json!(null), None),
|
||||
(serde_json::json!({}), Some(serde_json::json!({}))),
|
||||
@@ -1674,6 +1713,7 @@ mod tests {
|
||||
prompted_categories: Some("Allowed: {{ categories }}".to_string()),
|
||||
rejected_categories: Some("Denied: {{ categories }}".to_string()),
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
collaboration_modes: Some(CollaborationModeMessages {
|
||||
default: Some("default collaboration".to_string()),
|
||||
|
||||
Reference in New Issue
Block a user