feat(core) Introduce Feature::RequestPermissions (#11871)

## Summary
Introduces the initial implementation of Feature::RequestPermissions.
RequestPermissions allows the model to request that a command be run
inside the sandbox, with additional permissions, like writing to a
specific folder. Eventually this will include other rules as well, and
the ability to persist these permissions, but this PR is already quite
large - let's get the core flow working and go from there!

<img width="1279" height="541" alt="Screenshot 2026-02-15 at 2 26 22 PM"
src="https://github.com/user-attachments/assets/0ee3ec0f-02ec-4509-91a2-809ac80be368"
/>

## Testing
- [x] Added tests
- [x] Tested locally
- [x] Feature
This commit is contained in:
Dylan Hurd
2026-02-24 09:48:57 -08:00
committed by GitHub
parent 9a8adbf6e5
commit f6053fdfb3
43 changed files with 1471 additions and 77 deletions

View File

@@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use codex_utils_image::load_and_resize_to_fit;
use serde::Deserialize;
@@ -35,12 +36,34 @@ pub enum SandboxPermissions {
UseDefault,
/// Request to run outside the sandbox
RequireEscalated,
/// Request to run in the sandbox with additional per-command permissions.
WithAdditionalPermissions,
}
impl SandboxPermissions {
/// True if SandboxPermissions requires full unsandboxed execution (i.e. RequireEscalated)
pub fn requires_escalated_permissions(self) -> bool {
matches!(self, SandboxPermissions::RequireEscalated)
}
/// True if SandboxPermissions requires permissions beyond UseDefault
pub fn requires_additional_permissions(self) -> bool {
!matches!(self, SandboxPermissions::UseDefault)
}
}
#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
pub struct AdditionalPermissions {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fs_read: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fs_write: Vec<PathBuf>,
}
impl AdditionalPermissions {
pub fn is_empty(&self) -> bool {
self.fs_read.is_empty() && self.fs_write.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
@@ -227,6 +250,8 @@ const APPROVAL_POLICY_ON_FAILURE: &str =
include_str!("prompts/permissions/approval_policy/on_failure.md");
const APPROVAL_POLICY_ON_REQUEST_RULE: &str =
include_str!("prompts/permissions/approval_policy/on_request_rule.md");
const APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION: &str =
include_str!("prompts/permissions/approval_policy/on_request_rule_request_permission.md");
const SANDBOX_MODE_DANGER_FULL_ACCESS: &str =
include_str!("prompts/permissions/sandbox_mode/danger_full_access.md");
@@ -239,16 +264,25 @@ impl DeveloperInstructions {
Self { text: text.into() }
}
pub fn from(approval_policy: AskForApproval, exec_policy: &Policy) -> DeveloperInstructions {
pub fn from(
approval_policy: AskForApproval,
exec_policy: &Policy,
request_permission_enabled: bool,
) -> DeveloperInstructions {
let on_request_instructions = || {
let on_request_rule = if request_permission_enabled {
APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION
} else {
APPROVAL_POLICY_ON_REQUEST_RULE
};
let command_prefixes = format_allow_prefixes(exec_policy.get_allowed_prefixes());
match command_prefixes {
Some(prefixes) => {
format!(
"{APPROVAL_POLICY_ON_REQUEST_RULE}\n## Approved command prefixes\nThe following prefix rules have already been approved: {prefixes}"
"{on_request_rule}\n## Approved command prefixes\nThe following prefix rules have already been approved: {prefixes}"
)
}
None => APPROVAL_POLICY_ON_REQUEST_RULE.to_string(),
None => on_request_rule.to_string(),
}
};
let text = match approval_policy {
@@ -306,6 +340,7 @@ impl DeveloperInstructions {
approval_policy: AskForApproval,
exec_policy: &Policy,
cwd: &Path,
request_permission_enabled: bool,
) -> Self {
let network_access = if sandbox_policy.has_full_network_access() {
NetworkAccess::Enabled
@@ -329,6 +364,7 @@ impl DeveloperInstructions {
approval_policy,
exec_policy,
writable_roots,
request_permission_enabled,
)
}
@@ -352,6 +388,7 @@ impl DeveloperInstructions {
approval_policy: AskForApproval,
exec_policy: &Policy,
writable_roots: Option<Vec<WritableRoot>>,
request_permission_enabled: bool,
) -> Self {
let start_tag = DeveloperInstructions::new("<permissions instructions>");
let end_tag = DeveloperInstructions::new("</permissions instructions>");
@@ -360,7 +397,11 @@ impl DeveloperInstructions {
sandbox_mode,
network_access,
))
.concat(DeveloperInstructions::from(approval_policy, exec_policy))
.concat(DeveloperInstructions::from(
approval_policy,
exec_policy,
request_permission_enabled,
))
.concat(DeveloperInstructions::from_writable_roots(writable_roots))
.concat(end_tag)
}
@@ -757,6 +798,9 @@ pub struct ShellToolCallParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub prefix_rule: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub additional_permissions: Option<AdditionalPermissions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub justification: Option<String>,
}
@@ -780,6 +824,9 @@ pub struct ShellCommandToolCallParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub prefix_rule: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub additional_permissions: Option<AdditionalPermissions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub justification: Option<String>,
}
@@ -1205,6 +1252,7 @@ mod tests {
AskForApproval::OnRequest,
&Policy::empty(),
None,
false,
);
let text = instructions.into_text();
@@ -1233,6 +1281,7 @@ mod tests {
AskForApproval::UnlessTrusted,
&Policy::empty(),
&PathBuf::from("/tmp"),
false,
);
let text = instructions.into_text();
assert!(text.contains("Network access is enabled."));
@@ -1254,6 +1303,7 @@ mod tests {
AskForApproval::OnRequest,
&exec_policy,
None,
false,
);
let text = instructions.into_text();
@@ -1262,6 +1312,22 @@ mod tests {
assert!(text.contains(r#"["git", "pull"]"#));
}
#[test]
fn includes_request_permission_rule_instructions_for_on_request_when_enabled() {
let instructions = DeveloperInstructions::from_permissions_with_network(
SandboxMode::WorkspaceWrite,
NetworkAccess::Enabled,
AskForApproval::OnRequest,
&Policy::empty(),
None,
true,
);
let text = instructions.into_text();
assert!(text.contains("with_additional_permissions"));
assert!(text.contains("additional_permissions"));
}
#[test]
fn render_command_prefix_list_sorts_by_len_then_total_len_then_alphabetical() {
let prefixes = vec![
@@ -1572,6 +1638,7 @@ mod tests {
timeout_ms: Some(1000),
sandbox_permissions: None,
prefix_rule: None,
additional_permissions: None,
justification: None,
},
params