mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
guardian initial feedback / tweaks (#13897)
## Summary - remove the remaining model-visible guardian-specific `on-request` prompt additions so enabling the feature does not change the main approval-policy instructions - neutralize user-facing guardian wording to talk about automatic approval review / approval requests rather than a second reviewer or only sandbox escalations - tighten guardian retry-context handling so agent-authored `justification` stays in the structured action JSON and is not also injected as raw retry context - simplify guardian review plumbing in core by deleting dead prompt-append paths and trimming some request/transcript setup code ## Notable Changes - delete the dead `permissions/approval_policy/guardian.md` append path and stop threading `guardian_approval_enabled` through model-facing developer-instruction builders - rename the experimental feature copy to `Automatic approval review` and update the `/experimental` snapshot text accordingly - make approval-review status strings generic across shell, patch, network, and MCP review types - forward real sandbox/network retry reasons for shell and unified-exec guardian review, but do not pass agent-authored justification as raw retry context - simplify `guardian.rs` by removing the one-field request wrapper, deduping reasoning-effort selection, and cleaning up transcript entry collection ## Testing - `just fmt` - full validation left to CI --------- Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
co-authored by
Codex
parent
2bc3e52a91
commit
f23fcd6ced
+270
-77
@@ -12,15 +12,19 @@
|
||||
//! 4. Approve only low- and medium-risk actions (`risk_score < 80`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_protocol::approvals::NetworkApprovalProtocol;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
@@ -66,7 +70,7 @@ const GUARDIAN_RECENT_ENTRY_LIMIT: usize = 40;
|
||||
const GUARDIAN_TRUNCATION_TAG: &str = "guardian_truncated";
|
||||
|
||||
pub(crate) const GUARDIAN_REJECTION_MESSAGE: &str = concat!(
|
||||
"Guardian rejected this action due to unacceptable risk. ",
|
||||
"This action was rejected due to unacceptable risk. ",
|
||||
"The agent must not attempt to achieve the same outcome via workaround, ",
|
||||
"indirect execution, or policy circumvention. ",
|
||||
"Proceed only with a materially safer alternative, or stop and request user input.",
|
||||
@@ -89,12 +93,6 @@ pub(crate) fn is_guardian_subagent_source(
|
||||
)
|
||||
}
|
||||
|
||||
/// Canonical description of the action the guardian is being asked to review.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct GuardianReviewRequest {
|
||||
pub(crate) action: Value,
|
||||
}
|
||||
|
||||
/// Coarse risk label paired with the numeric `risk_score`.
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -120,6 +118,66 @@ pub(crate) struct GuardianAssessment {
|
||||
evidence: Vec<GuardianEvidence>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) enum GuardianApprovalRequest {
|
||||
Shell {
|
||||
command: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
sandbox_permissions: crate::sandboxing::SandboxPermissions,
|
||||
additional_permissions: Option<PermissionProfile>,
|
||||
justification: Option<String>,
|
||||
},
|
||||
ExecCommand {
|
||||
command: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
sandbox_permissions: crate::sandboxing::SandboxPermissions,
|
||||
additional_permissions: Option<PermissionProfile>,
|
||||
justification: Option<String>,
|
||||
tty: bool,
|
||||
},
|
||||
#[cfg(unix)]
|
||||
Execve {
|
||||
tool_name: String,
|
||||
program: String,
|
||||
argv: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
additional_permissions: Option<PermissionProfile>,
|
||||
},
|
||||
ApplyPatch {
|
||||
cwd: PathBuf,
|
||||
files: Vec<AbsolutePathBuf>,
|
||||
change_count: usize,
|
||||
patch: String,
|
||||
},
|
||||
NetworkAccess {
|
||||
target: String,
|
||||
host: String,
|
||||
protocol: NetworkApprovalProtocol,
|
||||
port: u16,
|
||||
},
|
||||
McpToolCall {
|
||||
server: String,
|
||||
tool_name: String,
|
||||
arguments: Option<Value>,
|
||||
connector_id: Option<String>,
|
||||
connector_name: Option<String>,
|
||||
connector_description: Option<String>,
|
||||
tool_title: Option<String>,
|
||||
tool_description: Option<String>,
|
||||
annotations: Option<GuardianMcpAnnotations>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct GuardianMcpAnnotations {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) destructive_hint: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) open_world_hint: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) read_only_hint: Option<bool>,
|
||||
}
|
||||
|
||||
/// Transcript entry retained for guardian review after filtering.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct GuardianTranscriptEntry {
|
||||
@@ -164,14 +222,11 @@ impl GuardianTranscriptEntryKind {
|
||||
async fn run_guardian_review(
|
||||
session: Arc<Session>,
|
||||
turn: Arc<TurnContext>,
|
||||
request: GuardianReviewRequest,
|
||||
request: GuardianApprovalRequest,
|
||||
retry_reason: Option<String>,
|
||||
) -> ReviewDecision {
|
||||
session
|
||||
.notify_background_event(
|
||||
turn.as_ref(),
|
||||
"Guardian assessing approval request...".to_string(),
|
||||
)
|
||||
.notify_background_event(turn.as_ref(), "Reviewing approval request...".to_string())
|
||||
.await;
|
||||
|
||||
let prompt_items = build_guardian_prompt_items(session.as_ref(), retry_reason, request).await;
|
||||
@@ -199,14 +254,15 @@ async fn run_guardian_review(
|
||||
Some(Err(err)) => GuardianAssessment {
|
||||
risk_level: GuardianRiskLevel::High,
|
||||
risk_score: 100,
|
||||
rationale: format!("Guardian review failed: {err}"),
|
||||
rationale: format!("Automatic approval review failed: {err}"),
|
||||
evidence: vec![],
|
||||
},
|
||||
None => GuardianAssessment {
|
||||
risk_level: GuardianRiskLevel::High,
|
||||
risk_score: 100,
|
||||
rationale: "Guardian review timed out while evaluating the requested approval."
|
||||
.to_string(),
|
||||
rationale:
|
||||
"Automatic approval review timed out while evaluating the requested approval."
|
||||
.to_string(),
|
||||
evidence: vec![],
|
||||
},
|
||||
};
|
||||
@@ -216,8 +272,7 @@ async fn run_guardian_review(
|
||||
// Emit a concise warning so the parent turn has an auditable summary of the
|
||||
// guardian decision without needing the full subagent transcript.
|
||||
let warning = format!(
|
||||
"Guardian {verdict} approval request ({}/100, {}): {}",
|
||||
assessment.risk_score,
|
||||
"Automatic approval review {verdict} (risk: {}): {}",
|
||||
assessment.risk_level.as_str(),
|
||||
assessment.rationale
|
||||
);
|
||||
@@ -239,7 +294,7 @@ async fn run_guardian_review(
|
||||
pub(crate) async fn review_approval_request(
|
||||
session: &Arc<Session>,
|
||||
turn: &Arc<TurnContext>,
|
||||
request: GuardianReviewRequest,
|
||||
request: GuardianApprovalRequest,
|
||||
retry_reason: Option<String>,
|
||||
) -> ReviewDecision {
|
||||
run_guardian_review(Arc::clone(session), Arc::clone(turn), request, retry_reason).await
|
||||
@@ -256,11 +311,11 @@ pub(crate) async fn review_approval_request(
|
||||
async fn build_guardian_prompt_items(
|
||||
session: &Session,
|
||||
retry_reason: Option<String>,
|
||||
request: GuardianReviewRequest,
|
||||
request: GuardianApprovalRequest,
|
||||
) -> Vec<UserInput> {
|
||||
let history = session.clone_history().await;
|
||||
let transcript_entries = collect_guardian_transcript_entries(history.raw_items());
|
||||
let planned_action_json = format_guardian_action_pretty(&request.action);
|
||||
let planned_action_json = format_guardian_action_pretty(&request);
|
||||
|
||||
let (transcript_entries, omission_note) =
|
||||
render_guardian_transcript_entries(transcript_entries.as_slice());
|
||||
@@ -400,6 +455,13 @@ fn render_guardian_transcript_entries(
|
||||
fn collect_guardian_transcript_entries(items: &[ResponseItem]) -> Vec<GuardianTranscriptEntry> {
|
||||
let mut entries = Vec::new();
|
||||
let mut tool_names_by_call_id = HashMap::new();
|
||||
let non_empty_entry = |kind, text: String| {
|
||||
(!text.trim().is_empty()).then_some(GuardianTranscriptEntry { kind, text })
|
||||
};
|
||||
let content_entry =
|
||||
|kind, content| content_items_to_text(content).and_then(|text| non_empty_entry(kind, text));
|
||||
let serialized_entry =
|
||||
|kind, serialized: Option<String>| serialized.and_then(|text| non_empty_entry(kind, text));
|
||||
|
||||
for item in items {
|
||||
let entry = match item {
|
||||
@@ -407,25 +469,16 @@ fn collect_guardian_transcript_entries(items: &[ResponseItem]) -> Vec<GuardianTr
|
||||
if is_contextual_user_message_content(content) {
|
||||
None
|
||||
} else {
|
||||
content_items_to_text(content).map(|text| GuardianTranscriptEntry {
|
||||
kind: GuardianTranscriptEntryKind::User,
|
||||
text,
|
||||
})
|
||||
content_entry(GuardianTranscriptEntryKind::User, content)
|
||||
}
|
||||
}
|
||||
ResponseItem::Message { role, content, .. } if role == "assistant" => {
|
||||
content_items_to_text(content).map(|text| GuardianTranscriptEntry {
|
||||
kind: GuardianTranscriptEntryKind::Assistant,
|
||||
text,
|
||||
})
|
||||
content_entry(GuardianTranscriptEntryKind::Assistant, content)
|
||||
}
|
||||
ResponseItem::LocalShellCall { action, .. } => serde_json::to_string(action)
|
||||
.ok()
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.map(|text| GuardianTranscriptEntry {
|
||||
kind: GuardianTranscriptEntryKind::Tool("tool shell call".to_string()),
|
||||
text,
|
||||
}),
|
||||
ResponseItem::LocalShellCall { action, .. } => serialized_entry(
|
||||
GuardianTranscriptEntryKind::Tool("tool shell call".to_string()),
|
||||
serde_json::to_string(action).ok(),
|
||||
),
|
||||
ResponseItem::FunctionCall {
|
||||
call_id,
|
||||
name,
|
||||
@@ -450,28 +503,26 @@ fn collect_guardian_transcript_entries(items: &[ResponseItem]) -> Vec<GuardianTr
|
||||
text: input.clone(),
|
||||
})
|
||||
}
|
||||
ResponseItem::WebSearchCall { action, .. } => action
|
||||
.as_ref()
|
||||
.and_then(|action| serde_json::to_string(action).ok())
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.map(|text| GuardianTranscriptEntry {
|
||||
kind: GuardianTranscriptEntryKind::Tool("tool web_search call".to_string()),
|
||||
text,
|
||||
}),
|
||||
ResponseItem::WebSearchCall { action, .. } => action.as_ref().and_then(|action| {
|
||||
serialized_entry(
|
||||
GuardianTranscriptEntryKind::Tool("tool web_search call".to_string()),
|
||||
serde_json::to_string(action).ok(),
|
||||
)
|
||||
}),
|
||||
ResponseItem::FunctionCallOutput { call_id, output }
|
||||
| ResponseItem::CustomToolCallOutput { call_id, output } => output
|
||||
.body
|
||||
.to_text()
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.map(|text| GuardianTranscriptEntry {
|
||||
kind: GuardianTranscriptEntryKind::Tool(
|
||||
tool_names_by_call_id.get(call_id).map_or_else(
|
||||
|| "tool result".to_string(),
|
||||
|name| format!("tool {name} result"),
|
||||
| ResponseItem::CustomToolCallOutput { call_id, output } => {
|
||||
output.body.to_text().and_then(|text| {
|
||||
non_empty_entry(
|
||||
GuardianTranscriptEntryKind::Tool(
|
||||
tool_names_by_call_id.get(call_id).map_or_else(
|
||||
|| "tool result".to_string(),
|
||||
|name| format!("tool {name} result"),
|
||||
),
|
||||
),
|
||||
),
|
||||
text,
|
||||
}),
|
||||
text,
|
||||
)
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -506,6 +557,13 @@ async fn run_guardian_subagent(
|
||||
.models_manager
|
||||
.list_models(crate::models_manager::manager::RefreshStrategy::Offline)
|
||||
.await;
|
||||
let preferred_reasoning_effort = |supports_low: bool, fallback| {
|
||||
if supports_low {
|
||||
Some(codex_protocol::openai_models::ReasoningEffort::Low)
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
};
|
||||
// Prefer `GUARDIAN_PREFERRED_MODEL` when the active provider exposes it,
|
||||
// but fall back to the parent turn's active model so guardian does not
|
||||
// become a blanket deny on providers or test environments that do not
|
||||
@@ -514,28 +572,23 @@ async fn run_guardian_subagent(
|
||||
.iter()
|
||||
.find(|preset| preset.model == GUARDIAN_PREFERRED_MODEL);
|
||||
let (guardian_model, guardian_reasoning_effort) = if let Some(preset) = preferred_model {
|
||||
let reasoning_effort = if preset
|
||||
.supported_reasoning_efforts
|
||||
.iter()
|
||||
.any(|effort| effort.effort == codex_protocol::openai_models::ReasoningEffort::Low)
|
||||
{
|
||||
Some(codex_protocol::openai_models::ReasoningEffort::Low)
|
||||
} else {
|
||||
Some(preset.default_reasoning_effort)
|
||||
};
|
||||
let reasoning_effort = preferred_reasoning_effort(
|
||||
preset
|
||||
.supported_reasoning_efforts
|
||||
.iter()
|
||||
.any(|effort| effort.effort == codex_protocol::openai_models::ReasoningEffort::Low),
|
||||
Some(preset.default_reasoning_effort),
|
||||
);
|
||||
(GUARDIAN_PREFERRED_MODEL.to_string(), reasoning_effort)
|
||||
} else {
|
||||
let reasoning_effort = if turn
|
||||
.model_info
|
||||
.supported_reasoning_levels
|
||||
.iter()
|
||||
.any(|preset| preset.effort == codex_protocol::openai_models::ReasoningEffort::Low)
|
||||
{
|
||||
Some(codex_protocol::openai_models::ReasoningEffort::Low)
|
||||
} else {
|
||||
let reasoning_effort = preferred_reasoning_effort(
|
||||
turn.model_info
|
||||
.supported_reasoning_levels
|
||||
.iter()
|
||||
.any(|preset| preset.effort == codex_protocol::openai_models::ReasoningEffort::Low),
|
||||
turn.reasoning_effort
|
||||
.or(turn.model_info.default_reasoning_level)
|
||||
};
|
||||
.or(turn.model_info.default_reasoning_level),
|
||||
);
|
||||
(turn.model_info.slug.clone(), reasoning_effort)
|
||||
};
|
||||
let guardian_config = build_guardian_subagent_config(
|
||||
@@ -678,9 +731,149 @@ fn truncate_guardian_action_value(value: Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_guardian_action_pretty(action: &Value) -> String {
|
||||
serde_json::to_string_pretty(&truncate_guardian_action_value(action.clone()))
|
||||
.unwrap_or_else(|_| "null".to_string())
|
||||
fn format_guardian_action_pretty(action: &GuardianApprovalRequest) -> String {
|
||||
let mut value = match action {
|
||||
GuardianApprovalRequest::Shell {
|
||||
command,
|
||||
cwd,
|
||||
sandbox_permissions,
|
||||
additional_permissions,
|
||||
justification,
|
||||
} => {
|
||||
let mut action = serde_json::json!({
|
||||
"tool": "shell",
|
||||
"command": command,
|
||||
"cwd": cwd,
|
||||
"sandbox_permissions": sandbox_permissions,
|
||||
"additional_permissions": additional_permissions,
|
||||
"justification": justification,
|
||||
});
|
||||
if let Some(action) = action.as_object_mut() {
|
||||
if additional_permissions.is_none() {
|
||||
action.remove("additional_permissions");
|
||||
}
|
||||
if justification.is_none() {
|
||||
action.remove("justification");
|
||||
}
|
||||
}
|
||||
action
|
||||
}
|
||||
GuardianApprovalRequest::ExecCommand {
|
||||
command,
|
||||
cwd,
|
||||
sandbox_permissions,
|
||||
additional_permissions,
|
||||
justification,
|
||||
tty,
|
||||
} => {
|
||||
let mut action = serde_json::json!({
|
||||
"tool": "exec_command",
|
||||
"command": command,
|
||||
"cwd": cwd,
|
||||
"sandbox_permissions": sandbox_permissions,
|
||||
"additional_permissions": additional_permissions,
|
||||
"justification": justification,
|
||||
"tty": tty,
|
||||
});
|
||||
if let Some(action) = action.as_object_mut() {
|
||||
if additional_permissions.is_none() {
|
||||
action.remove("additional_permissions");
|
||||
}
|
||||
if justification.is_none() {
|
||||
action.remove("justification");
|
||||
}
|
||||
}
|
||||
action
|
||||
}
|
||||
#[cfg(unix)]
|
||||
GuardianApprovalRequest::Execve {
|
||||
tool_name,
|
||||
program,
|
||||
argv,
|
||||
cwd,
|
||||
additional_permissions,
|
||||
} => {
|
||||
let mut action = serde_json::json!({
|
||||
"tool": tool_name,
|
||||
"program": program,
|
||||
"argv": argv,
|
||||
"cwd": cwd,
|
||||
"additional_permissions": additional_permissions,
|
||||
});
|
||||
if let Some(action) = action.as_object_mut()
|
||||
&& additional_permissions.is_none()
|
||||
{
|
||||
action.remove("additional_permissions");
|
||||
}
|
||||
action
|
||||
}
|
||||
GuardianApprovalRequest::ApplyPatch {
|
||||
cwd,
|
||||
files,
|
||||
change_count,
|
||||
patch,
|
||||
} => serde_json::json!({
|
||||
"tool": "apply_patch",
|
||||
"cwd": cwd,
|
||||
"files": files,
|
||||
"change_count": change_count,
|
||||
"patch": patch,
|
||||
}),
|
||||
GuardianApprovalRequest::NetworkAccess {
|
||||
target,
|
||||
host,
|
||||
protocol,
|
||||
port,
|
||||
} => serde_json::json!({
|
||||
"tool": "network_access",
|
||||
"target": target,
|
||||
"host": host,
|
||||
"protocol": protocol,
|
||||
"port": port,
|
||||
}),
|
||||
GuardianApprovalRequest::McpToolCall {
|
||||
server,
|
||||
tool_name,
|
||||
arguments,
|
||||
connector_id,
|
||||
connector_name,
|
||||
connector_description,
|
||||
tool_title,
|
||||
tool_description,
|
||||
annotations,
|
||||
} => {
|
||||
let mut action = serde_json::json!({
|
||||
"tool": "mcp_tool_call",
|
||||
"server": server,
|
||||
"tool_name": tool_name,
|
||||
"arguments": arguments,
|
||||
"connector_id": connector_id,
|
||||
"connector_name": connector_name,
|
||||
"connector_description": connector_description,
|
||||
"tool_title": tool_title,
|
||||
"tool_description": tool_description,
|
||||
"annotations": annotations,
|
||||
});
|
||||
if let Some(action) = action.as_object_mut() {
|
||||
for key in [
|
||||
("arguments", arguments.is_none()),
|
||||
("connector_id", connector_id.is_none()),
|
||||
("connector_name", connector_name.is_none()),
|
||||
("connector_description", connector_description.is_none()),
|
||||
("tool_title", tool_title.is_none()),
|
||||
("tool_description", tool_description.is_none()),
|
||||
("annotations", annotations.is_none()),
|
||||
] {
|
||||
if key.1 {
|
||||
action.remove(key.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
action
|
||||
}
|
||||
};
|
||||
value = truncate_guardian_action_value(value);
|
||||
serde_json::to_string_pretty(&value).unwrap_or_else(|_| "null".to_string())
|
||||
}
|
||||
|
||||
fn guardian_truncate_text(content: &str, token_cap: usize) -> String {
|
||||
|
||||
Reference in New Issue
Block a user