Skip to content

Commit 28ff383

Browse files
authored
Surface Guardian reviews as tool calls (#190)
1 parent 5a0a2c2 commit 28ff383

5 files changed

Lines changed: 428 additions & 3 deletions

src/CodexEventHandler.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@ import type {
1515
ConfigWarningNotification,
1616
ErrorNotification,
1717
GuardianWarningNotification,
18+
ItemGuardianApprovalReviewCompletedNotification,
19+
ItemGuardianApprovalReviewStartedNotification,
1820
ItemCompletedNotification,
19-
ItemStartedNotification, ThreadItem,
21+
ItemStartedNotification,
22+
ThreadItem,
2023
ModelReroutedNotification,
2124
ThreadTokenUsageUpdatedNotification,
2225
TurnPlanUpdatedNotification,
@@ -28,6 +31,8 @@ import {
2831
createCommandExecutionUpdate,
2932
createDynamicToolCallUpdate,
3033
createFileChangeUpdate,
34+
createGuardianApprovalReviewToolCall,
35+
createGuardianApprovalReviewToolCallUpdate,
3136
createMcpRawInput,
3237
createMcpRawOutput,
3338
createFuzzyFileSearchComplete,
@@ -45,6 +50,7 @@ export class CodexEventHandler {
4550
private readonly sessionState: SessionState;
4651
private failure: RequestError | null = null;
4752
private readonly activeFuzzyFileSearchSessions = new Set<string>();
53+
private readonly activeGuardianApprovalReviews = new Set<string>();
4854

4955
constructor(connection: acp.AgentSideConnection, sessionState: SessionState) {
5056
this.connection = connection;
@@ -124,6 +130,10 @@ export class CodexEventHandler {
124130
return this.createWarningEvent(notification.params);
125131
case "guardianWarning":
126132
return this.createGuardianWarningEvent(notification.params);
133+
case "item/autoApprovalReview/started":
134+
return this.handleGuardianApprovalReviewStarted(notification.params);
135+
case "item/autoApprovalReview/completed":
136+
return this.handleGuardianApprovalReviewCompleted(notification.params);
127137
case "thread/compacted":
128138
return {
129139
sessionUpdate: "agent_message_chunk",
@@ -140,8 +150,6 @@ export class CodexEventHandler {
140150
return this.handleFuzzyFileSearchSessionCompleted(notification.params);
141151
// ignored events
142152
case "command/exec/outputDelta":
143-
case "item/autoApprovalReview/started":
144-
case "item/autoApprovalReview/completed":
145153
case "hook/started":
146154
case "hook/completed":
147155
case "item/reasoning/summaryTextDelta":
@@ -486,4 +494,23 @@ export class CodexEventHandler {
486494
this.activeFuzzyFileSearchSessions.delete(toolCallId);
487495
return createFuzzyFileSearchComplete(params);
488496
}
497+
498+
private handleGuardianApprovalReviewStarted(
499+
params: ItemGuardianApprovalReviewStartedNotification
500+
): UpdateSessionEvent {
501+
if (this.activeGuardianApprovalReviews.has(params.reviewId)) {
502+
return createGuardianApprovalReviewToolCallUpdate(params);
503+
}
504+
this.activeGuardianApprovalReviews.add(params.reviewId);
505+
return createGuardianApprovalReviewToolCall(params);
506+
}
507+
508+
private handleGuardianApprovalReviewCompleted(
509+
params: ItemGuardianApprovalReviewCompletedNotification
510+
): UpdateSessionEvent {
511+
if (this.activeGuardianApprovalReviews.delete(params.reviewId)) {
512+
return createGuardianApprovalReviewToolCallUpdate(params);
513+
}
514+
return createGuardianApprovalReviewToolCall(params);
515+
}
489516
}

src/CodexToolCallMapper.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ import type {
1313
CommandExecutionStatus,
1414
DynamicToolCallStatus,
1515
FileUpdateChange,
16+
GuardianApprovalReview,
17+
GuardianApprovalReviewAction,
18+
GuardianApprovalReviewStatus,
19+
GuardianCommandSource,
20+
ItemGuardianApprovalReviewCompletedNotification,
21+
ItemGuardianApprovalReviewStartedNotification,
1622
McpToolCallError,
1723
McpToolCallResult,
1824
McpToolCallStatus,
@@ -24,6 +30,9 @@ import {logger} from "./Logger";
2430

2531
type CodexItemStatus = CommandExecutionStatus | PatchApplyStatus | McpToolCallStatus | DynamicToolCallStatus;
2632
type AcpToolCallStatus = "pending" | "in_progress" | "completed" | "failed";
33+
type GuardianApprovalReviewNotification =
34+
| ItemGuardianApprovalReviewStartedNotification
35+
| ItemGuardianApprovalReviewCompletedNotification;
2736

2837
function toAcpStatus(status: CodexItemStatus): AcpToolCallStatus {
2938
switch (status) {
@@ -143,6 +152,36 @@ export function createMcpRawOutput(
143152
};
144153
}
145154

155+
export function guardianApprovalReviewToolCallId(reviewId: string): string {
156+
return `guardian_assessment:${reviewId}`;
157+
}
158+
159+
export function createGuardianApprovalReviewToolCall(
160+
event: GuardianApprovalReviewNotification,
161+
): UpdateSessionEvent {
162+
return {
163+
sessionUpdate: "tool_call",
164+
toolCallId: guardianApprovalReviewToolCallId(event.reviewId),
165+
kind: "think",
166+
title: "Guardian Review",
167+
status: toAcpGuardianApprovalReviewStatus(event.review.status),
168+
content: createGuardianApprovalReviewContent(event.review, event.action),
169+
rawInput: event as unknown as Record<string, JsonValue>,
170+
};
171+
}
172+
173+
export function createGuardianApprovalReviewToolCallUpdate(
174+
event: GuardianApprovalReviewNotification,
175+
): UpdateSessionEvent {
176+
return {
177+
sessionUpdate: "tool_call_update",
178+
toolCallId: guardianApprovalReviewToolCallId(event.reviewId),
179+
status: toAcpGuardianApprovalReviewStatus(event.review.status),
180+
content: createGuardianApprovalReviewContent(event.review, event.action),
181+
rawOutput: event as unknown as Record<string, JsonValue>,
182+
};
183+
}
184+
146185
export function fuzzyFileSearchToolCallId(sessionId: string): string {
147186
return `fuzzyFileSearch.${sessionId}`;
148187
}
@@ -257,6 +296,108 @@ function createSearchTitle(query: string | null, path: string | null): string {
257296
return "Search";
258297
}
259298

299+
function toAcpGuardianApprovalReviewStatus(status: GuardianApprovalReviewStatus): AcpToolCallStatus {
300+
switch (status) {
301+
case "inProgress":
302+
return "in_progress";
303+
case "approved":
304+
return "completed";
305+
case "denied":
306+
case "aborted":
307+
case "timedOut":
308+
return "failed";
309+
}
310+
}
311+
312+
function createGuardianApprovalReviewContent(
313+
review: GuardianApprovalReview,
314+
action: GuardianApprovalReviewAction,
315+
): ToolCallContent[] {
316+
const lines = [`Status: ${formatGuardianApprovalReviewStatus(review.status)}`];
317+
const actionSummary = createGuardianApprovalReviewActionSummary(action);
318+
if (actionSummary) {
319+
lines.push(`Action: ${actionSummary}`);
320+
}
321+
if (review.riskLevel) {
322+
lines.push(`Risk: ${review.riskLevel}`);
323+
}
324+
if (review.rationale?.trim()) {
325+
lines.push(`Rationale: ${review.rationale}`);
326+
}
327+
328+
return [{
329+
type: "content",
330+
content: {
331+
type: "text",
332+
text: lines.join("\n"),
333+
},
334+
}];
335+
}
336+
337+
function formatGuardianApprovalReviewStatus(status: GuardianApprovalReviewStatus): string {
338+
switch (status) {
339+
case "inProgress":
340+
return "In progress";
341+
case "approved":
342+
return "Approved";
343+
case "denied":
344+
return "Denied";
345+
case "aborted":
346+
return "Aborted";
347+
case "timedOut":
348+
return "Timed out";
349+
}
350+
}
351+
352+
function createGuardianApprovalReviewActionSummary(action: GuardianApprovalReviewAction): string | null {
353+
switch (action.type) {
354+
case "command":
355+
return `${guardianCommandSourceLabel(action.source)} ${action.command}`;
356+
case "execve": {
357+
const command = action.argv.length > 0 ? action.argv : [action.program];
358+
return `${guardianCommandSourceLabel(action.source)} ${shellJoin(command)}`;
359+
}
360+
case "applyPatch":
361+
if (action.files.length === 1) {
362+
return `apply_patch touching ${action.files[0]}`;
363+
}
364+
return `apply_patch touching ${action.files.length} files`;
365+
case "networkAccess": {
366+
const label = action.target.length > 0 ? action.target : action.host;
367+
return `network access to ${label}`;
368+
}
369+
case "mcpToolCall": {
370+
const label = action.connectorName ?? action.server;
371+
return `MCP ${action.toolName} on ${label}`;
372+
}
373+
case "requestPermissions":
374+
return action.reason ?? "request additional permissions";
375+
}
376+
}
377+
378+
function guardianCommandSourceLabel(source: GuardianCommandSource): string {
379+
switch (source) {
380+
case "shell":
381+
return "shell";
382+
case "unifiedExec":
383+
return "exec";
384+
}
385+
}
386+
387+
function shellJoin(args: string[]): string {
388+
return args.map(shellQuote).join(" ");
389+
}
390+
391+
function shellQuote(arg: string): string {
392+
if (arg.length === 0) {
393+
return "''";
394+
}
395+
if (/^[A-Za-z0-9_/:=+.,@%-]+$/.test(arg)) {
396+
return arg;
397+
}
398+
return `'${arg.replace(/'/g, `'\\''`)}'`;
399+
}
400+
260401
async function createPatchContent(change: FileUpdateChange): Promise<ToolCallContent | null> {
261402
try {
262403
switch (change.kind.type) {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"method": "sessionUpdate",
3+
"args": [
4+
{
5+
"sessionId": "test-session-id",
6+
"update": {
7+
"sessionUpdate": "tool_call",
8+
"toolCallId": "guardian_assessment:review-orphaned",
9+
"kind": "think",
10+
"title": "Guardian Review",
11+
"status": "failed",
12+
"content": [
13+
{
14+
"type": "content",
15+
"content": {
16+
"type": "text",
17+
"text": "Status: Denied\nAction: network access to api.example.com\nRisk: high\nRationale: The network target is not permitted."
18+
}
19+
}
20+
],
21+
"rawInput": {
22+
"threadId": "test-session-id",
23+
"turnId": "turn-1",
24+
"startedAtMs": 1000,
25+
"completedAtMs": 1800,
26+
"reviewId": "review-orphaned",
27+
"targetItemId": null,
28+
"decisionSource": "agent",
29+
"review": {
30+
"status": "denied",
31+
"riskLevel": "high",
32+
"userAuthorization": "low",
33+
"rationale": "The network target is not permitted."
34+
},
35+
"action": {
36+
"type": "networkAccess",
37+
"target": "",
38+
"host": "api.example.com",
39+
"protocol": "https",
40+
"port": 443
41+
}
42+
}
43+
}
44+
}
45+
]
46+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
{
2+
"method": "sessionUpdate",
3+
"args": [
4+
{
5+
"sessionId": "test-session-id",
6+
"update": {
7+
"sessionUpdate": "tool_call",
8+
"toolCallId": "guardian_assessment:review-1",
9+
"kind": "think",
10+
"title": "Guardian Review",
11+
"status": "in_progress",
12+
"content": [
13+
{
14+
"type": "content",
15+
"content": {
16+
"type": "text",
17+
"text": "Status: In progress\nAction: exec /bin/ls -l\nRisk: medium\nRationale: Checking whether this command should run automatically."
18+
}
19+
}
20+
],
21+
"rawInput": {
22+
"threadId": "test-session-id",
23+
"turnId": "turn-1",
24+
"startedAtMs": 1000,
25+
"reviewId": "review-1",
26+
"targetItemId": "command-1",
27+
"review": {
28+
"status": "inProgress",
29+
"riskLevel": "medium",
30+
"userAuthorization": "unknown",
31+
"rationale": "Checking whether this command should run automatically."
32+
},
33+
"action": {
34+
"type": "execve",
35+
"source": "unifiedExec",
36+
"program": "/bin/ls",
37+
"argv": [
38+
"/bin/ls",
39+
"-l"
40+
],
41+
"cwd": "/test/project"
42+
}
43+
}
44+
}
45+
}
46+
]
47+
}
48+
{
49+
"method": "sessionUpdate",
50+
"args": [
51+
{
52+
"sessionId": "test-session-id",
53+
"update": {
54+
"sessionUpdate": "tool_call_update",
55+
"toolCallId": "guardian_assessment:review-1",
56+
"status": "completed",
57+
"content": [
58+
{
59+
"type": "content",
60+
"content": {
61+
"type": "text",
62+
"text": "Status: Approved\nAction: exec /bin/ls -l\nRisk: low\nRationale: The command only lists files."
63+
}
64+
}
65+
],
66+
"rawOutput": {
67+
"threadId": "test-session-id",
68+
"turnId": "turn-1",
69+
"startedAtMs": 1000,
70+
"completedAtMs": 1500,
71+
"reviewId": "review-1",
72+
"targetItemId": "command-1",
73+
"decisionSource": "agent",
74+
"review": {
75+
"status": "approved",
76+
"riskLevel": "low",
77+
"userAuthorization": "medium",
78+
"rationale": "The command only lists files."
79+
},
80+
"action": {
81+
"type": "execve",
82+
"source": "unifiedExec",
83+
"program": "/bin/ls",
84+
"argv": [
85+
"/bin/ls",
86+
"-l"
87+
],
88+
"cwd": "/test/project"
89+
}
90+
}
91+
}
92+
}
93+
]
94+
}

0 commit comments

Comments
 (0)