fix(guardian, app-server): introduce guardian review ids (#17298)

## Description

This PR introduces `review_id` as the stable identifier for guardian
reviews and exposes it in app-server `item/autoApprovalReview/started`
and `item/autoApprovalReview/completed` events.

Internally, guardian rejection state is now keyed by `review_id` instead
of the reviewed tool item ID. `target_item_id` is still included when a
review maps to a concrete thread item, but it is no longer overloaded as
the review lifecycle identifier.

## Motivation

We'd like to give users the ability to preempt a guardian review while
it's running (approve or decline).

However, we can't implement the API that allows the user to override a
running guardian review because we didn't have a unique `review_id` per
guardian review. Using `target_item_id` is not correct since:
- with execve reviews, there can be multiple execve calls (and therefore
guardian reviews) per shell command
- with network policy reviews, there is no target item ID

The PR that actually implements user overrides will use `review_id` as
the stable identifier.
This commit is contained in:
Owen Lin
2026-04-10 16:21:02 -07:00
committed by GitHub
parent 7999b0f60f
commit a3be74143a
36 changed files with 577 additions and 172 deletions

View File

@@ -11,6 +11,7 @@
//! - The projection is presentation-specific. Core protocol events stay generic, while the
//! app-server protocol decides how to surface those events as `ThreadItem`s for clients.
use crate::protocol::common::ServerNotification;
use crate::protocol::v2::AutoReviewDecisionSource;
use crate::protocol::v2::CommandAction;
use crate::protocol::v2::CommandExecutionSource;
use crate::protocol::v2::CommandExecutionStatus;
@@ -142,12 +143,13 @@ pub fn build_item_from_guardian_event(
) -> Option<ThreadItem> {
match &assessment.action {
GuardianAssessmentAction::Command { command, cwd, .. } => {
let id = assessment.target_item_id.as_ref()?;
let command = command.clone();
let command_actions = vec![CommandAction::Unknown {
command: command.clone(),
}];
Some(ThreadItem::CommandExecution {
id: assessment.id.clone(),
id: id.clone(),
command,
cwd: cwd.clone(),
process_id: None,
@@ -162,6 +164,7 @@ pub fn build_item_from_guardian_event(
GuardianAssessmentAction::Execve {
program, argv, cwd, ..
} => {
let id = assessment.target_item_id.as_ref()?;
let argv = if argv.is_empty() {
vec![program.clone()]
} else {
@@ -179,7 +182,7 @@ pub fn build_item_from_guardian_event(
parsed_cmd.into_iter().map(CommandAction::from).collect()
};
Some(ThreadItem::CommandExecution {
id: assessment.id.clone(),
id: id.clone(),
command,
cwd: cwd.clone(),
process_id: None,
@@ -202,9 +205,6 @@ pub fn guardian_auto_approval_review_notification(
event_turn_id: &str,
assessment: &GuardianAssessmentEvent,
) -> ServerNotification {
// TODO(ccunningham): Attach guardian review state to the reviewed tool
// item's lifecycle instead of sending standalone review notifications so
// the app-server API can persist and replay review state via `thread/read`.
let turn_id = if assessment.turn_id.is_empty() {
event_turn_id.to_string()
} else {
@@ -236,7 +236,8 @@ pub fn guardian_auto_approval_review_notification(
ItemGuardianApprovalReviewStartedNotification {
thread_id: conversation_id.to_string(),
turn_id,
target_item_id: assessment.id.clone(),
review_id: assessment.id.clone(),
target_item_id: assessment.target_item_id.clone(),
review,
action,
},
@@ -249,7 +250,12 @@ pub fn guardian_auto_approval_review_notification(
ItemGuardianApprovalReviewCompletedNotification {
thread_id: conversation_id.to_string(),
turn_id,
target_item_id: assessment.id.clone(),
review_id: assessment.id.clone(),
target_item_id: assessment.target_item_id.clone(),
decision_source: assessment
.decision_source
.map(AutoReviewDecisionSource::from)
.unwrap_or(AutoReviewDecisionSource::Agent),
review,
action,
},