Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions src/CodexEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ import type {
ConfigWarningNotification,
ErrorNotification,
GuardianWarningNotification,
ItemGuardianApprovalReviewCompletedNotification,
ItemGuardianApprovalReviewStartedNotification,
ItemCompletedNotification,
ItemStartedNotification, ThreadItem,
ItemStartedNotification,
ThreadItem,
ModelReroutedNotification,
ThreadTokenUsageUpdatedNotification,
TurnPlanUpdatedNotification,
Expand All @@ -28,6 +31,8 @@ import {
createCommandExecutionUpdate,
createDynamicToolCallUpdate,
createFileChangeUpdate,
createGuardianApprovalReviewToolCall,
createGuardianApprovalReviewToolCallUpdate,
createMcpRawInput,
createMcpRawOutput,
createFuzzyFileSearchComplete,
Expand All @@ -45,6 +50,7 @@ export class CodexEventHandler {
private readonly sessionState: SessionState;
private failure: RequestError | null = null;
private readonly activeFuzzyFileSearchSessions = new Set<string>();
private readonly activeGuardianApprovalReviews = new Set<string>();

constructor(connection: acp.AgentSideConnection, sessionState: SessionState) {
this.connection = connection;
Expand Down Expand Up @@ -124,6 +130,10 @@ export class CodexEventHandler {
return this.createWarningEvent(notification.params);
case "guardianWarning":
return this.createGuardianWarningEvent(notification.params);
case "item/autoApprovalReview/started":
return this.handleGuardianApprovalReviewStarted(notification.params);
case "item/autoApprovalReview/completed":
return this.handleGuardianApprovalReviewCompleted(notification.params);
case "thread/compacted":
return {
sessionUpdate: "agent_message_chunk",
Expand All @@ -140,8 +150,6 @@ export class CodexEventHandler {
return this.handleFuzzyFileSearchSessionCompleted(notification.params);
// ignored events
case "command/exec/outputDelta":
case "item/autoApprovalReview/started":
case "item/autoApprovalReview/completed":
case "hook/started":
case "hook/completed":
case "item/reasoning/summaryTextDelta":
Expand Down Expand Up @@ -486,4 +494,23 @@ export class CodexEventHandler {
this.activeFuzzyFileSearchSessions.delete(toolCallId);
return createFuzzyFileSearchComplete(params);
}

private handleGuardianApprovalReviewStarted(
params: ItemGuardianApprovalReviewStartedNotification
): UpdateSessionEvent {
if (this.activeGuardianApprovalReviews.has(params.reviewId)) {
return createGuardianApprovalReviewToolCallUpdate(params);
}
this.activeGuardianApprovalReviews.add(params.reviewId);
return createGuardianApprovalReviewToolCall(params);
}

private handleGuardianApprovalReviewCompleted(
params: ItemGuardianApprovalReviewCompletedNotification
): UpdateSessionEvent {
if (this.activeGuardianApprovalReviews.delete(params.reviewId)) {
return createGuardianApprovalReviewToolCallUpdate(params);
}
return createGuardianApprovalReviewToolCall(params);
}
}
141 changes: 141 additions & 0 deletions src/CodexToolCallMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import type {
CommandExecutionStatus,
DynamicToolCallStatus,
FileUpdateChange,
GuardianApprovalReview,
GuardianApprovalReviewAction,
GuardianApprovalReviewStatus,
GuardianCommandSource,
ItemGuardianApprovalReviewCompletedNotification,
ItemGuardianApprovalReviewStartedNotification,
McpToolCallError,
McpToolCallResult,
McpToolCallStatus,
Expand All @@ -24,6 +30,9 @@ import {logger} from "./Logger";

type CodexItemStatus = CommandExecutionStatus | PatchApplyStatus | McpToolCallStatus | DynamicToolCallStatus;
type AcpToolCallStatus = "pending" | "in_progress" | "completed" | "failed";
type GuardianApprovalReviewNotification =
| ItemGuardianApprovalReviewStartedNotification
| ItemGuardianApprovalReviewCompletedNotification;

function toAcpStatus(status: CodexItemStatus): AcpToolCallStatus {
switch (status) {
Expand Down Expand Up @@ -143,6 +152,36 @@ export function createMcpRawOutput(
};
}

export function guardianApprovalReviewToolCallId(reviewId: string): string {
return `guardian_assessment:${reviewId}`;
}

export function createGuardianApprovalReviewToolCall(
event: GuardianApprovalReviewNotification,
): UpdateSessionEvent {
return {
sessionUpdate: "tool_call",
toolCallId: guardianApprovalReviewToolCallId(event.reviewId),
kind: "think",
title: "Guardian Review",
status: toAcpGuardianApprovalReviewStatus(event.review.status),
content: createGuardianApprovalReviewContent(event.review, event.action),
rawInput: event as unknown as Record<string, JsonValue>,
};
}

export function createGuardianApprovalReviewToolCallUpdate(
event: GuardianApprovalReviewNotification,
): UpdateSessionEvent {
return {
sessionUpdate: "tool_call_update",
toolCallId: guardianApprovalReviewToolCallId(event.reviewId),
status: toAcpGuardianApprovalReviewStatus(event.review.status),
content: createGuardianApprovalReviewContent(event.review, event.action),
rawOutput: event as unknown as Record<string, JsonValue>,
};
}

export function fuzzyFileSearchToolCallId(sessionId: string): string {
return `fuzzyFileSearch.${sessionId}`;
}
Expand Down Expand Up @@ -257,6 +296,108 @@ function createSearchTitle(query: string | null, path: string | null): string {
return "Search";
}

function toAcpGuardianApprovalReviewStatus(status: GuardianApprovalReviewStatus): AcpToolCallStatus {
switch (status) {
case "inProgress":
return "in_progress";
case "approved":
return "completed";
case "denied":
case "aborted":
case "timedOut":
return "failed";
}
}

function createGuardianApprovalReviewContent(
review: GuardianApprovalReview,
action: GuardianApprovalReviewAction,
): ToolCallContent[] {
const lines = [`Status: ${formatGuardianApprovalReviewStatus(review.status)}`];
const actionSummary = createGuardianApprovalReviewActionSummary(action);
if (actionSummary) {
lines.push(`Action: ${actionSummary}`);
}
if (review.riskLevel) {
lines.push(`Risk: ${review.riskLevel}`);
}
if (review.rationale?.trim()) {
lines.push(`Rationale: ${review.rationale}`);
}

return [{
type: "content",
content: {
type: "text",
text: lines.join("\n"),
},
}];
}

function formatGuardianApprovalReviewStatus(status: GuardianApprovalReviewStatus): string {
switch (status) {
case "inProgress":
return "In progress";
case "approved":
return "Approved";
case "denied":
return "Denied";
case "aborted":
return "Aborted";
case "timedOut":
return "Timed out";
}
}

function createGuardianApprovalReviewActionSummary(action: GuardianApprovalReviewAction): string | null {
switch (action.type) {
case "command":
return `${guardianCommandSourceLabel(action.source)} ${action.command}`;
case "execve": {
const command = action.argv.length > 0 ? action.argv : [action.program];
return `${guardianCommandSourceLabel(action.source)} ${shellJoin(command)}`;
}
case "applyPatch":
if (action.files.length === 1) {
return `apply_patch touching ${action.files[0]}`;
}
return `apply_patch touching ${action.files.length} files`;
case "networkAccess": {
const label = action.target.length > 0 ? action.target : action.host;
return `network access to ${label}`;
}
case "mcpToolCall": {
const label = action.connectorName ?? action.server;
return `MCP ${action.toolName} on ${label}`;
}
case "requestPermissions":
return action.reason ?? "request additional permissions";
}
}

function guardianCommandSourceLabel(source: GuardianCommandSource): string {
switch (source) {
case "shell":
return "shell";
case "unifiedExec":
return "exec";
}
}

function shellJoin(args: string[]): string {
return args.map(shellQuote).join(" ");
}

function shellQuote(arg: string): string {
if (arg.length === 0) {
return "''";
}
if (/^[A-Za-z0-9_/:=+.,@%-]+$/.test(arg)) {
return arg;
}
return `'${arg.replace(/'/g, `'\\''`)}'`;
}

async function createPatchContent(change: FileUpdateChange): Promise<ToolCallContent | null> {
try {
switch (change.kind.type) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"method": "sessionUpdate",
"args": [
{
"sessionId": "test-session-id",
"update": {
"sessionUpdate": "tool_call",
"toolCallId": "guardian_assessment:review-orphaned",
"kind": "think",
"title": "Guardian Review",
"status": "failed",
"content": [
{
"type": "content",
"content": {
"type": "text",
"text": "Status: Denied\nAction: network access to api.example.com\nRisk: high\nRationale: The network target is not permitted."
}
}
],
"rawInput": {
"threadId": "test-session-id",
"turnId": "turn-1",
"startedAtMs": 1000,
"completedAtMs": 1800,
"reviewId": "review-orphaned",
"targetItemId": null,
"decisionSource": "agent",
"review": {
"status": "denied",
"riskLevel": "high",
"userAuthorization": "low",
"rationale": "The network target is not permitted."
},
"action": {
"type": "networkAccess",
"target": "",
"host": "api.example.com",
"protocol": "https",
"port": 443
}
}
}
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
{
"method": "sessionUpdate",
"args": [
{
"sessionId": "test-session-id",
"update": {
"sessionUpdate": "tool_call",
"toolCallId": "guardian_assessment:review-1",
"kind": "think",
"title": "Guardian Review",
"status": "in_progress",
"content": [
{
"type": "content",
"content": {
"type": "text",
"text": "Status: In progress\nAction: exec /bin/ls -l\nRisk: medium\nRationale: Checking whether this command should run automatically."
}
}
],
"rawInput": {
"threadId": "test-session-id",
"turnId": "turn-1",
"startedAtMs": 1000,
"reviewId": "review-1",
"targetItemId": "command-1",
"review": {
"status": "inProgress",
"riskLevel": "medium",
"userAuthorization": "unknown",
"rationale": "Checking whether this command should run automatically."
},
"action": {
"type": "execve",
"source": "unifiedExec",
"program": "/bin/ls",
"argv": [
"/bin/ls",
"-l"
],
"cwd": "/test/project"
}
}
}
}
]
}
{
"method": "sessionUpdate",
"args": [
{
"sessionId": "test-session-id",
"update": {
"sessionUpdate": "tool_call_update",
"toolCallId": "guardian_assessment:review-1",
"status": "completed",
"content": [
{
"type": "content",
"content": {
"type": "text",
"text": "Status: Approved\nAction: exec /bin/ls -l\nRisk: low\nRationale: The command only lists files."
}
}
],
"rawOutput": {
"threadId": "test-session-id",
"turnId": "turn-1",
"startedAtMs": 1000,
"completedAtMs": 1500,
"reviewId": "review-1",
"targetItemId": "command-1",
"decisionSource": "agent",
"review": {
"status": "approved",
"riskLevel": "low",
"userAuthorization": "medium",
"rationale": "The command only lists files."
},
"action": {
"type": "execve",
"source": "unifiedExec",
"program": "/bin/ls",
"argv": [
"/bin/ls",
"-l"
],
"cwd": "/test/project"
}
}
}
}
]
}
Loading
Loading