fix(guardian): make GuardianAssessmentEvent.action strongly typed (#16448)

## Description

Previously the `action` field on `EventMsg::GuardianAssessment`, which
describes what Guardian is reviewing, was typed as an arbitrary JSON
blob. This PR cleans it up and defines a sum type representing all the
various actions that Guardian can review.

This is a breaking change (on purpose), which is fine because:
- the Codex app / VSCE does not actually use `action` at the moment
- the TUI code that consumes `action` is updated in this PR as well
- rollout files that serialized old `EventMsg::GuardianAssessment` will
just silently drop these guardian events
- the contract is defined as unstable, so other clients have a fair
warning :)

This will make things much easier for followup Guardian work.

## Why

The old guardian review payloads worked, but they pushed too much shape
knowledge into downstream consumers. The TUI had custom JSON parsing
logic for commands, patches, network requests, and MCP calls, and the
app-server protocol was effectively just passing through an opaque blob.

Typing this at the protocol boundary makes the contract clearer.
This commit is contained in:
Owen Lin
2026-04-01 15:42:18 -07:00
committed by GitHub
parent f83f3fa2a6
commit 30f6786d62
30 changed files with 1869 additions and 616 deletions

View File

@@ -516,7 +516,6 @@ async fn handle_patch_approval(
} = event;
let approval_id = call_id.clone();
let guardian_decision = if routes_approval_to_guardian(parent_ctx) {
let change_count = changes.len();
let maybe_files = changes
.keys()
.map(|path| parent_ctx.cwd.join(path).ok())
@@ -557,7 +556,6 @@ async fn handle_patch_approval(
id: approval_id.clone(),
cwd: parent_ctx.cwd.to_path_buf(),
files,
change_count,
patch,
},
reason.clone(),

View File

@@ -9,8 +9,10 @@ use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecApprovalRequestEvent;
use codex_protocol::protocol::GuardianAssessmentAction;
use codex_protocol::protocol::GuardianAssessmentEvent;
use codex_protocol::protocol::GuardianAssessmentStatus;
use codex_protocol::protocol::GuardianCommandSource;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::RawResponseItemEvent;
use codex_protocol::protocol::ReviewDecision;
@@ -23,7 +25,6 @@ use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::request_user_input::RequestUserInputQuestion;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
@@ -317,11 +318,11 @@ async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_f
risk_score: None,
risk_level: None,
rationale: None,
action: Some(json!({
"tool": "shell",
"command": "rm -rf tmp",
"cwd": "/tmp",
})),
action: GuardianAssessmentAction::Command {
source: GuardianCommandSource::Shell,
command: "rm -rf tmp".to_string(),
cwd: "/tmp".into(),
},
}
);

View File

@@ -1,5 +1,8 @@
use std::path::Path;
use std::path::PathBuf;
use codex_protocol::approvals::GuardianAssessmentAction;
use codex_protocol::approvals::GuardianCommandSource;
use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -31,7 +34,7 @@ pub(crate) enum GuardianApprovalRequest {
#[cfg(unix)]
Execve {
id: String,
tool_name: String,
source: GuardianCommandSource,
program: String,
argv: Vec<String>,
cwd: PathBuf,
@@ -41,7 +44,6 @@ pub(crate) enum GuardianApprovalRequest {
id: String,
cwd: PathBuf,
files: Vec<AbsolutePathBuf>,
change_count: usize,
patch: String,
},
NetworkAccess {
@@ -80,7 +82,7 @@ pub(crate) struct GuardianMcpAnnotations {
struct CommandApprovalAction<'a> {
tool: &'a str,
command: &'a [String],
cwd: &'a PathBuf,
cwd: &'a Path,
sandbox_permissions: crate::sandboxing::SandboxPermissions,
#[serde(skip_serializing_if = "Option::is_none")]
additional_permissions: Option<&'a PermissionProfile>,
@@ -96,7 +98,7 @@ struct ExecveApprovalAction<'a> {
tool: &'a str,
program: &'a str,
argv: &'a [String],
cwd: &'a PathBuf,
cwd: &'a Path,
#[serde(skip_serializing_if = "Option::is_none")]
additional_permissions: Option<&'a PermissionProfile>,
}
@@ -129,7 +131,7 @@ fn serialize_guardian_action(value: impl Serialize) -> serde_json::Result<Value>
fn serialize_command_guardian_action(
tool: &'static str,
command: &[String],
cwd: &PathBuf,
cwd: &Path,
sandbox_permissions: crate::sandboxing::SandboxPermissions,
additional_permissions: Option<&PermissionProfile>,
justification: Option<&String>,
@@ -146,12 +148,23 @@ fn serialize_command_guardian_action(
})
}
fn command_assessment_action_value(tool: &'static str, command: &[String], cwd: &PathBuf) -> Value {
serde_json::json!({
"tool": tool,
"command": codex_shell_command::parse_command::shlex_join(command),
"cwd": cwd,
})
fn command_assessment_action(
source: GuardianCommandSource,
command: &[String],
cwd: &Path,
) -> GuardianAssessmentAction {
GuardianAssessmentAction::Command {
source,
command: codex_shell_command::parse_command::shlex_join(command),
cwd: cwd.to_path_buf(),
}
}
fn guardian_command_source_tool_name(source: GuardianCommandSource) -> &'static str {
match source {
GuardianCommandSource::Shell => "shell",
GuardianCommandSource::UnifiedExec => "exec_command",
}
}
fn truncate_guardian_action_value(value: Value) -> Value {
@@ -220,13 +233,13 @@ pub(crate) fn guardian_approval_request_to_json(
#[cfg(unix)]
GuardianApprovalRequest::Execve {
id: _,
tool_name,
source,
program,
argv,
cwd,
additional_permissions,
} => serialize_guardian_action(ExecveApprovalAction {
tool: tool_name,
tool: guardian_command_source_tool_name(*source),
program,
argv,
cwd,
@@ -236,13 +249,11 @@ pub(crate) fn guardian_approval_request_to_json(
id: _,
cwd,
files,
change_count,
patch,
} => Ok(serde_json::json!({
"tool": "apply_patch",
"cwd": cwd,
"files": files,
"change_count": change_count,
"patch": patch,
})),
GuardianApprovalRequest::NetworkAccess {
@@ -285,38 +296,38 @@ pub(crate) fn guardian_approval_request_to_json(
}
}
pub(crate) fn guardian_assessment_action_value(action: &GuardianApprovalRequest) -> Value {
pub(crate) fn guardian_assessment_action(
action: &GuardianApprovalRequest,
) -> GuardianAssessmentAction {
match action {
GuardianApprovalRequest::Shell { command, cwd, .. } => {
command_assessment_action_value("shell", command, cwd)
command_assessment_action(GuardianCommandSource::Shell, command, cwd)
}
GuardianApprovalRequest::ExecCommand { command, cwd, .. } => {
command_assessment_action_value("exec_command", command, cwd)
command_assessment_action(GuardianCommandSource::UnifiedExec, command, cwd)
}
#[cfg(unix)]
GuardianApprovalRequest::Execve {
tool_name,
source,
program,
argv,
cwd,
..
} => serde_json::json!({
"tool": tool_name,
"program": program,
"argv": argv,
"cwd": cwd,
}),
GuardianApprovalRequest::ApplyPatch {
cwd,
files,
change_count,
..
} => serde_json::json!({
"tool": "apply_patch",
"cwd": cwd,
"files": files,
"change_count": change_count,
}),
} => GuardianAssessmentAction::Execve {
source: *source,
program: program.clone(),
argv: argv.clone(),
cwd: cwd.clone(),
},
GuardianApprovalRequest::ApplyPatch { cwd, files, .. } => {
GuardianAssessmentAction::ApplyPatch {
cwd: cwd.clone(),
files: files
.iter()
.map(codex_utils_absolute_path::AbsolutePathBuf::to_path_buf)
.collect(),
}
}
GuardianApprovalRequest::NetworkAccess {
id: _,
turn_id: _,
@@ -324,20 +335,26 @@ pub(crate) fn guardian_assessment_action_value(action: &GuardianApprovalRequest)
host,
protocol,
port,
} => serde_json::json!({
"tool": "network_access",
"target": target,
"host": host,
"protocol": protocol,
"port": port,
}),
} => GuardianAssessmentAction::NetworkAccess {
target: target.clone(),
host: host.clone(),
protocol: *protocol,
port: *port,
},
GuardianApprovalRequest::McpToolCall {
server, tool_name, ..
} => serde_json::json!({
"tool": "mcp_tool_call",
"server": server,
"tool_name": tool_name,
}),
server,
tool_name,
connector_id,
connector_name,
tool_title,
..
} => GuardianAssessmentAction::McpToolCall {
server: server.clone(),
tool_name: tool_name.clone(),
connector_id: connector_id.clone(),
connector_name: connector_name.clone(),
tool_title: tool_title.clone(),
},
}
}

View File

@@ -62,7 +62,7 @@ pub(crate) struct GuardianAssessment {
#[cfg(test)]
use approval_request::format_guardian_action_pretty;
#[cfg(test)]
use approval_request::guardian_assessment_action_value;
use approval_request::guardian_assessment_action;
#[cfg(test)]
use approval_request::guardian_request_turn_id;
#[cfg(test)]

View File

@@ -18,7 +18,7 @@ use super::GUARDIAN_APPROVAL_RISK_THRESHOLD;
use super::GUARDIAN_REVIEWER_NAME;
use super::GuardianApprovalRequest;
use super::GuardianAssessment;
use super::approval_request::guardian_assessment_action_value;
use super::approval_request::guardian_assessment_action;
use super::approval_request::guardian_request_id;
use super::approval_request::guardian_request_turn_id;
use super::prompt::build_guardian_prompt_items;
@@ -81,7 +81,7 @@ async fn run_guardian_review(
) -> ReviewDecision {
let assessment_id = guardian_request_id(&request).to_string();
let assessment_turn_id = guardian_request_turn_id(&request, &turn.sub_id).to_string();
let action_summary = guardian_assessment_action_value(&request);
let action_summary = guardian_assessment_action(&request);
session
.send_event(
turn.as_ref(),
@@ -92,7 +92,7 @@ async fn run_guardian_review(
risk_score: None,
risk_level: None,
rationale: None,
action: Some(action_summary.clone()),
action: action_summary.clone(),
}),
)
.await;
@@ -111,7 +111,7 @@ async fn run_guardian_review(
risk_score: None,
risk_level: None,
rationale: None,
action: Some(action_summary),
action: action_summary,
}),
)
.await;
@@ -161,7 +161,7 @@ async fn run_guardian_review(
risk_score: None,
risk_level: None,
rationale: None,
action: Some(action_summary),
action: action_summary,
}),
)
.await;
@@ -197,7 +197,7 @@ async fn run_guardian_review(
risk_score: Some(assessment.risk_score),
risk_level: Some(assessment.risk_level),
rationale: Some(assessment.rationale.clone()),
action: Some(terminal_action),
action: terminal_action,
}),
)
.await;

View File

@@ -265,7 +265,6 @@ fn format_guardian_action_pretty_truncates_large_string_fields() -> serde_json::
id: "patch-1".to_string(),
cwd: PathBuf::from("/tmp"),
files: Vec::new(),
change_count: 1usize,
patch: patch.clone(),
};
@@ -318,7 +317,7 @@ fn guardian_approval_request_to_json_renders_mcp_tool_call_shape() -> serde_json
}
#[test]
fn guardian_assessment_action_value_redacts_apply_patch_patch_text() {
fn guardian_assessment_action_redacts_apply_patch_patch_text() {
let (cwd, file) = if cfg!(windows) {
(r"C:\tmp", r"C:\tmp\guardian.txt")
} else {
@@ -330,19 +329,17 @@ fn guardian_assessment_action_value_redacts_apply_patch_patch_text() {
id: "patch-1".to_string(),
cwd: cwd.clone(),
files: vec![file.clone()],
change_count: 1usize,
patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+secret\n*** End Patch"
.to_string(),
};
assert_eq!(
guardian_assessment_action_value(&action),
serde_json::to_value(guardian_assessment_action(&action)).expect("serialize action"),
serde_json::json!({
"tool": "apply_patch",
"type": "apply_patch",
"cwd": cwd,
"files": [file],
"change_count": 1,
})
}),
);
}
@@ -360,7 +357,6 @@ fn guardian_request_turn_id_prefers_network_access_owner_turn() {
id: "patch-1".to_string(),
cwd: PathBuf::from("/tmp"),
files: vec![PathBuf::from("/tmp/guardian.txt").abs()],
change_count: 1usize,
patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch"
.to_string(),
};
@@ -388,7 +384,6 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning() {
id: "patch-1".to_string(),
cwd: PathBuf::from("/tmp"),
files: vec![PathBuf::from("/tmp/guardian.txt").abs()],
change_count: 1usize,
patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch"
.to_string(),
},

View File

@@ -60,7 +60,6 @@ impl ApplyPatchRuntime {
id: call_id.to_string(),
cwd: req.action.cwd.clone(),
files: req.file_paths.clone(),
change_count: req.changes.len(),
patch: req.action.patch.clone(),
}
}

View File

@@ -63,7 +63,6 @@ fn guardian_review_request_includes_patch_context() {
id: "call-1".to_string(),
cwd: expected_cwd,
files: request.file_paths,
change_count: 1usize,
patch: expected_patch,
}
);

View File

@@ -27,6 +27,7 @@ use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::GuardianCommandSource;
use codex_protocol::protocol::NetworkPolicyRuleAction;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
@@ -187,7 +188,7 @@ pub(super) async fn try_run_zsh_fork(
session: Arc::clone(&ctx.session),
turn: Arc::clone(&ctx.turn),
call_id: ctx.call_id.clone(),
tool_name: "shell",
tool_name: GuardianCommandSource::Shell,
approval_policy: ctx.turn.approval_policy.value(),
sandbox_policy: command_executor.sandbox_policy.clone(),
file_system_sandbox_policy: command_executor.file_system_sandbox_policy.clone(),
@@ -259,7 +260,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork(
session: Arc::clone(&ctx.session),
turn: Arc::clone(&ctx.turn),
call_id: ctx.call_id.clone(),
tool_name: "exec_command",
tool_name: GuardianCommandSource::UnifiedExec,
approval_policy: ctx.turn.approval_policy.value(),
sandbox_policy: exec_request.sandbox_policy.clone(),
file_system_sandbox_policy: exec_request.file_system_sandbox_policy.clone(),
@@ -294,7 +295,7 @@ struct CoreShellActionProvider {
session: Arc<crate::codex::Session>,
turn: Arc<crate::codex::TurnContext>,
call_id: String,
tool_name: &'static str,
tool_name: GuardianCommandSource,
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
file_system_sandbox_policy: FileSystemSandboxPolicy,
@@ -380,7 +381,7 @@ impl CoreShellActionProvider {
let turn = self.turn.clone();
let call_id = self.call_id.clone();
let approval_id = Some(Uuid::new_v4().to_string());
let tool_name = self.tool_name;
let source = self.tool_name;
Ok(stopwatch
.pause_for(async move {
if routes_approval_to_guardian(&turn) {
@@ -389,7 +390,7 @@ impl CoreShellActionProvider {
&turn,
GuardianApprovalRequest::Execve {
id: call_id.clone(),
tool_name: tool_name.to_string(),
source,
program: program.to_string_lossy().into_owned(),
argv: argv.to_vec(),
cwd: workdir,