Skip to content
Merged
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
43 changes: 43 additions & 0 deletions plugins/trace-codex/src/agents/codex/event-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,49 @@ describe("CodexEventProcessor: tool spans", () => {
});

describe("CodexEventProcessor: permissions", () => {
test("a SKILL.md read is normalized as a skill load tool span", async () => {
await assertProducesTrace(
[
sessionStart(),
sessionMeta({ cwd: "/work" }),
taskStarted({ turn_id: "t1" }),
functionCall({
turn_id: "t1",
name: "exec_command",
call_id: "c1",
arguments: JSON.stringify({ command: "cat /home/user/.agents/skills/review/SKILL.md" }),
}),
functionCallOutput({ call_id: "c1", output: "loaded" }),
taskComplete({ turn_id: "t1", last_agent_message: "done" }),
stop({ turn_id: "t1" }),
],
{
span_attributes: { name: "codex: work", type: "task" },
ended: true,
children: [
{
span_attributes: { name: "turn: t1", type: "task" },
ended: true,
children: [
{ span_attributes: { name: "llm", type: "llm" }, ended: true },
{
span_attributes: { name: "skill: review", type: "tool" },
metadata: {
tool_name: "skill",
original_tool_name: "exec_command",
call_id: "c1",
skill_name: "review",
skill_path: "/home/user/.agents/skills/review/SKILL.md",
},
ended: true,
},
],
},
],
},
);
});

test("an escalated tool call is annotated with permission metadata and a tag", async () => {
await assertProducesTrace(
[
Expand Down
98 changes: 95 additions & 3 deletions plugins/trace-codex/src/agents/codex/event-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
// tool calls, later) render in execution order. Buffered spans are delivered via
// flush(). Everything is wrapped so tracing can never break a Codex turn.

import { basename, dirname } from "node:path";

import {
defaultSpanFactoryProvider,
type ReportingConfig,
Expand Down Expand Up @@ -410,6 +412,11 @@ interface PermissionInfo {
prefix_rule?: unknown;
}

interface SkillLoadInfo {
skillName?: string;
skillPath?: string;
}

/**
* Parse a tool call's permission/escalation request from its arguments. Codex
* records an escalated retry's request inline in the function_call arguments
Expand Down Expand Up @@ -439,6 +446,81 @@ function permissionInfo(args: unknown): PermissionInfo | undefined {
return info;
}

function parseArgsObject(args: unknown): Record<string, unknown> | undefined {
if (typeof args === "string") {
try {
const parsed = JSON.parse(args) as unknown;
return parsed !== null && typeof parsed === "object"
? (parsed as Record<string, unknown>)
: undefined;
} catch {
return undefined;
}
}
return args !== null && typeof args === "object" ? (args as Record<string, unknown>) : undefined;
}

function stringCandidates(args: unknown): string[] {
const candidates: string[] = [];
if (typeof args === "string") candidates.push(args);
const obj = parseArgsObject(args);
if (obj !== undefined) {
for (const key of ["path", "file_path", "filePath", "file", "command", "cmd", "resource"]) {
const value = obj[key];
if (typeof value === "string") candidates.push(value);
}
}
return candidates;
}

function detectCodexSkillLoad(
toolName: string | undefined,
args: unknown,
): SkillLoadInfo | undefined {
if (toolName === "skills.read") {
const obj = parseArgsObject(args);
const skillName =
typeof obj?.name === "string"
? obj.name
: typeof obj?.package === "string"
? obj.package
: undefined;
return {
skillName,
};
}

for (const candidate of stringCandidates(args)) {
const skillPath = candidate.match(/(?:^|[\s"'])([^\s"']*SKILL\.md)(?:$|[\s"'])/i)?.[1];
if (skillPath !== undefined) {
return {
skillName: basename(dirname(skillPath)),
skillPath,
};
}

const scriptPath = candidate.match(
/(?:^|[\s"'])([^\s"']*[\\/]scripts[\\/][^\s"']+)(?:$|[\s"'])/i,
)?.[1];
if (scriptPath !== undefined) {
return {
skillName: basename(dirname(dirname(scriptPath))),
skillPath: scriptPath,
};
}
}

return undefined;
}

function skillLoadMetadata(info: SkillLoadInfo | undefined): Record<string, unknown> {
if (info === undefined) return {};
return {
...(info.skillName !== undefined ? { skill_name: info.skillName } : {}),
...(info.skillPath !== undefined ? { skill_path: info.skillPath } : {}),
};
}

// ConversationItems (chat messages / reasoning) are already plain JSON, so they
// round-trip through a snapshot unchanged. These two helpers just bridge the
// nominal type boundary between the live union and the snapshot's record array
Expand Down Expand Up @@ -1649,21 +1731,31 @@ export class CodexEventProcessor implements EventProcessor {
// If this call requested escalated permissions (Codex records the request
// inline in the arguments), surface it on the span as metadata and a tag.
const permission = permissionInfo(input);
const skillLoad = detectCodexSkillLoad(name, input);
const startTime = isoToUnixSeconds(record.timestamp);
const toolName = name ?? "tool";
const spanName =
skillLoad?.skillName !== undefined ? `skill: ${skillLoad.skillName}` : toolName;
try {
const toolSpan = this.trackSpan(
turnSpan.startSpan({
name: toolName,
name: spanName,
type: "tool",
...(startTime !== undefined ? { startTime } : {}),
event: {
input,
metadata: { tool_name: name, call_id: callId, turn_id: turnId, permission },
metadata: {
tool_name: skillLoad !== undefined ? "skill" : name,
...(skillLoad !== undefined ? { original_tool_name: name } : {}),
call_id: callId,
turn_id: turnId,
permission,
...skillLoadMetadata(skillLoad),
},
...(permission !== undefined ? { tags: [PERMISSION_TAG] } : {}),
},
}),
toolName,
spanName,
"tool",
startTime,
);
Expand Down
Loading