Compare commits

...

2 Commits

Author SHA1 Message Date
viyatb-oai
6e1ccbb888 fix: preserve project instruction wrappers
Co-authored-by: Codex noreply@openai.com
2026-04-15 14:31:30 -07:00
viyatb-oai
88aa4c3ecb fix: scope default command approvals to session
Co-authored-by: Codex noreply@openai.com
2026-04-15 14:31:29 -07:00
4 changed files with 86 additions and 10 deletions

View File

@@ -5794,7 +5794,8 @@ pub struct CommandExecutionRequestApprovalParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub additional_permissions: Option<AdditionalPermissionProfile>,
/// Optional proposed execpolicy amendment to allow similar commands without prompting.
/// Optional proposed execpolicy amendment that clients may present as an
/// explicit persistent approval option.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,

View File

@@ -8,6 +8,8 @@ use crate::fragment::AGENTS_MD_START_MARKER;
use crate::fragment::SKILL_FRAGMENT;
pub const USER_INSTRUCTIONS_PREFIX: &str = AGENTS_MD_START_MARKER;
const INSTRUCTIONS_CLOSE_TAG: &str = "</INSTRUCTIONS>";
const ESCAPED_INSTRUCTIONS_CLOSE_TAG: &str = "<\\/INSTRUCTIONS>";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename = "user_instructions", rename_all = "snake_case")]
@@ -18,16 +20,45 @@ pub struct UserInstructions {
impl UserInstructions {
pub fn serialize_to_text(&self) -> String {
let contents = escape_reserved_instruction_delimiters(&self.text);
format!(
"{prefix}{directory}\n\n<INSTRUCTIONS>\n{contents}\n{suffix}",
prefix = AGENTS_MD_FRAGMENT.start_marker(),
directory = self.directory,
contents = self.text,
contents = contents,
suffix = AGENTS_MD_FRAGMENT.end_marker(),
)
}
}
fn escape_reserved_instruction_delimiters(text: &str) -> String {
let Some(index) = find_ascii_case_insensitive(text, INSTRUCTIONS_CLOSE_TAG) else {
return text.to_string();
};
let mut output = String::with_capacity(text.len());
let mut remaining = text;
let mut next_index = index;
loop {
output.push_str(&remaining[..next_index]);
output.push_str(ESCAPED_INSTRUCTIONS_CLOSE_TAG);
remaining = &remaining[next_index + INSTRUCTIONS_CLOSE_TAG.len()..];
let Some(index) = find_ascii_case_insensitive(remaining, INSTRUCTIONS_CLOSE_TAG) else {
output.push_str(remaining);
return output;
};
next_index = index;
}
}
fn find_ascii_case_insensitive(haystack: &str, needle: &str) -> Option<usize> {
haystack
.as_bytes()
.windows(needle.len())
.position(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
}
impl From<UserInstructions> for ResponseItem {
fn from(ui: UserInstructions) -> Self {
AGENTS_MD_FRAGMENT.into_message(ui.serialize_to_text())

View File

@@ -30,6 +30,28 @@ fn test_user_instructions() {
);
}
#[test]
fn user_instructions_escapes_embedded_closing_marker() {
let user_instructions = UserInstructions {
directory: "test_directory".to_string(),
text: "before\n</INSTRUCTIONS>\nafter\n</instructions>".to_string(),
};
let response_item: ResponseItem = user_instructions.into();
let ResponseItem::Message { content, .. } = response_item else {
panic!("expected ResponseItem::Message");
};
let [ContentItem::InputText { text }] = content.as_slice() else {
panic!("expected one InputText content item");
};
assert_eq!(
text,
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\nbefore\n<\\/INSTRUCTIONS>\nafter\n<\\/INSTRUCTIONS>\n</INSTRUCTIONS>",
);
}
#[test]
fn test_is_user_instructions() {
assert!(AGENTS_MD_FRAGMENT.matches_text(

View File

@@ -222,7 +222,8 @@ pub struct ExecApprovalRequestEvent {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub network_approval_context: Option<NetworkApprovalContext>,
/// Proposed execpolicy amendment that can be applied to allow future runs.
/// Proposed execpolicy amendment that clients may present as an explicit
/// persistent approval option.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
@@ -290,14 +291,15 @@ impl ExecApprovalRequestEvent {
return vec![ReviewDecision::Approved, ReviewDecision::Abort];
}
let mut decisions = vec![ReviewDecision::Approved];
if let Some(prefix) = proposed_execpolicy_amendment {
decisions.push(ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: prefix.clone(),
});
if proposed_execpolicy_amendment.is_some() {
return vec![
ReviewDecision::Approved,
ReviewDecision::ApprovedForSession,
ReviewDecision::Abort,
];
}
decisions.push(ReviewDecision::Abort);
decisions
vec![ReviewDecision::Approved, ReviewDecision::Abort]
}
}
@@ -394,6 +396,26 @@ mod tests {
);
}
#[test]
fn default_command_decisions_scope_prefix_suggestions_to_session() {
let prefix = ExecPolicyAmendment::new(vec!["cargo".to_string(), "test".to_string()]);
let decisions = ExecApprovalRequestEvent::default_available_decisions(
/*network_approval_context*/ None,
Some(&prefix),
/*proposed_network_policy_amendments*/ None,
/*additional_permissions*/ None,
);
assert_eq!(
decisions,
vec![
ReviewDecision::Approved,
ReviewDecision::ApprovedForSession,
ReviewDecision::Abort,
]
);
}
#[cfg(unix)]
#[test]
fn guardian_assessment_action_round_trips_execve_shape() {