Skip to content

Commit aa9da72

Browse files
committed
feat: mcp activity history for Codex
1 parent e2f3d2d commit aa9da72

2 files changed

Lines changed: 287 additions & 9 deletions

File tree

ggshield/verticals/ai/agents/codex.py

Lines changed: 104 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
2+
from datetime import datetime
23
from pathlib import Path
3-
from typing import Any, Dict, Iterator, Literal
4+
from typing import Any, Dict, Iterator, Literal, Optional
45

56
import click
67
from pygitguardian.models import AIDiscovery, MCPActivityRequest
@@ -109,20 +110,114 @@ def parse_mcp_activity(
109110
tool = parts[-1]
110111
server_cfg_name = "__".join(parts[1:-1])
111112

112-
server_name = server_cfg_name
113-
for server in ai_config.servers:
114-
for configuration in server.configurations:
115-
if _mangle_server_name(configuration.name) == server_cfg_name:
116-
server_name = server.name
117-
break
118-
119113
return MCPActivityRequest(
120114
user=ai_config.user,
121115
tool=tool,
122-
server=server_name,
116+
server=self._resolve_server_name(server_cfg_name, ai_config),
123117
agent=self.name,
124118
model=payload.raw.get("model", ""),
125119
cwd=payload.raw.get("cwd", ""),
126120
input=payload.raw.get("tool_input", {}),
127121
timestamp=payload.timestamp,
128122
)
123+
124+
def iter_history_events(
125+
self, ai_config: Optional[AIDiscovery]
126+
) -> Iterator[MCPActivityRequest]:
127+
"""Walk every Codex session rollout and yield its MCP tool_use events."""
128+
for path in self._history_files():
129+
yield from self._parse_session_file(path, ai_config)
130+
131+
def _history_files(self) -> Iterator[Path]:
132+
"""Yield every Codex session rollout file we know about."""
133+
yield from sorted(self.config_folder.glob("sessions/*/*/*/rollout-*.jsonl"))
134+
135+
def _parse_session_file(
136+
self, path: Path, ai_config: Optional[AIDiscovery]
137+
) -> Iterator[MCPActivityRequest]:
138+
"""Yield MCPActivityRequest events from a single Codex session rollout.
139+
140+
Codex stores one session per JSONL file. Each line is a typed envelope
141+
with ``timestamp``, ``type``, ``payload``. MCP tool calls appear as
142+
``response_item`` payloads of type ``function_call`` whose ``namespace``
143+
starts with ``mcp__``. ``cwd`` is seeded by ``session_meta`` and may be
144+
updated per-turn by ``turn_context``; ``model`` only appears on
145+
``turn_context``.
146+
"""
147+
cwd = ""
148+
model = ""
149+
for entry in self._load_jsonl_file(path):
150+
if not isinstance(entry, dict):
151+
continue
152+
payload = entry.get("payload")
153+
if not isinstance(payload, dict):
154+
continue
155+
entry_type = entry.get("type")
156+
if entry_type == "session_meta":
157+
cwd = payload.get("cwd") or cwd
158+
continue
159+
if entry_type == "turn_context":
160+
cwd = payload.get("cwd") or cwd
161+
model = payload.get("model") or model
162+
continue
163+
if entry_type != "response_item":
164+
continue
165+
if payload.get("type") != "function_call":
166+
continue
167+
namespace = payload.get("namespace") or ""
168+
if not namespace.startswith("mcp__"):
169+
continue
170+
event = self._build_activity_from_function_call(
171+
entry, payload, namespace, cwd, model, ai_config
172+
)
173+
if event is not None:
174+
yield event
175+
176+
def _build_activity_from_function_call(
177+
self,
178+
entry: Dict[str, Any],
179+
payload: Dict[str, Any],
180+
namespace: str,
181+
cwd: str,
182+
model: str,
183+
ai_config: Optional[AIDiscovery],
184+
) -> Optional[MCPActivityRequest]:
185+
"""Turn one function_call response_item into an MCPActivityRequest."""
186+
tool = payload.get("name") or ""
187+
if not tool:
188+
return None
189+
server_cfg_name = namespace.removeprefix("mcp__").removesuffix("__")
190+
try:
191+
tool_input = json.loads(payload.get("arguments") or "{}")
192+
except (json.JSONDecodeError, TypeError):
193+
tool_input = {}
194+
if not isinstance(tool_input, dict):
195+
tool_input = {}
196+
try:
197+
ts = datetime.fromisoformat(
198+
str(entry.get("timestamp", "")).replace("Z", "+00:00")
199+
)
200+
except ValueError:
201+
return None
202+
return MCPActivityRequest(
203+
user=self._user_or_default(ai_config),
204+
tool=tool,
205+
server=self._resolve_server_name(server_cfg_name, ai_config),
206+
agent=self.name,
207+
model=model,
208+
cwd=cwd,
209+
input=tool_input,
210+
timestamp=ts,
211+
)
212+
213+
def _resolve_server_name(
214+
self, cfg_name: str, ai_config: Optional[AIDiscovery]
215+
) -> str:
216+
"""Look up the canonical server name; fall back to the configuration name."""
217+
if ai_config is None:
218+
return cfg_name
219+
for server in ai_config.servers:
220+
for configuration in server.configurations:
221+
if _mangle_server_name(configuration.name) == cfg_name:
222+
return server.name
223+
return cfg_name
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import json
2+
from datetime import datetime, timezone
3+
from pathlib import Path
4+
from unittest.mock import patch
5+
6+
import pytest
7+
from pygitguardian.models import AIDiscovery, MCPConfiguration, MCPServer, UserInfo
8+
9+
from ggshield.verticals.ai.agents.codex import Codex
10+
11+
12+
def _function_call_entry(
13+
*,
14+
name: str = "get_issue",
15+
namespace: str = "mcp__linear__",
16+
arguments: str = '{"id": "NHI-1"}',
17+
timestamp: str = "2026-04-01T09:00:00.000Z",
18+
) -> dict:
19+
"""Build a Codex JSONL response_item line carrying an MCP function_call."""
20+
return {
21+
"timestamp": timestamp,
22+
"type": "response_item",
23+
"payload": {
24+
"type": "function_call",
25+
"name": name,
26+
"namespace": namespace,
27+
"arguments": arguments,
28+
"call_id": "call_AAA",
29+
},
30+
}
31+
32+
33+
def _session_meta(cwd: str = "/home/u/repo") -> dict:
34+
return {
35+
"timestamp": "2026-04-01T08:55:00.000Z",
36+
"type": "session_meta",
37+
"payload": {"id": "sess-1", "cwd": cwd, "cli_version": "0.130.0"},
38+
}
39+
40+
41+
def _turn_context(cwd: str = "/home/u/repo", model: str = "gpt-5.5") -> dict:
42+
return {
43+
"timestamp": "2026-04-01T08:56:00.000Z",
44+
"type": "turn_context",
45+
"payload": {"turn_id": "turn-1", "cwd": cwd, "model": model},
46+
}
47+
48+
49+
def _write_session(path: Path, entries: list[dict]) -> None:
50+
path.write_text("\n".join(json.dumps(e) for e in entries) + "\n")
51+
52+
53+
@pytest.fixture
54+
def empty_ai_config() -> AIDiscovery:
55+
return AIDiscovery(
56+
user=UserInfo(hostname="h", username="u", machine_id="m"),
57+
servers=[],
58+
discovery_duration=0.0,
59+
)
60+
61+
62+
class TestCodexParseSessionFile:
63+
def test_extracts_mcp_tool_use(self, tmp_path: Path, empty_ai_config) -> None:
64+
path = tmp_path / "rollout.jsonl"
65+
_write_session(
66+
path,
67+
[
68+
_session_meta(),
69+
_turn_context(model="gpt-5.5"),
70+
_function_call_entry(),
71+
],
72+
)
73+
74+
events = list(Codex()._parse_session_file(path, empty_ai_config))
75+
76+
assert len(events) == 1
77+
ev = events[0]
78+
assert ev.tool == "get_issue"
79+
assert ev.server == "linear"
80+
assert ev.agent == "codex"
81+
assert ev.model == "gpt-5.5"
82+
assert ev.cwd == "/home/u/repo"
83+
assert ev.input == {"id": "NHI-1"}
84+
assert ev.timestamp == datetime(2026, 4, 1, 9, 0, tzinfo=timezone.utc)
85+
86+
def test_ignores_non_mcp_function_calls(
87+
self, tmp_path: Path, empty_ai_config
88+
) -> None:
89+
path = tmp_path / "rollout.jsonl"
90+
_write_session(
91+
path,
92+
[
93+
_session_meta(),
94+
_turn_context(),
95+
# Built-in shell tool: no namespace.
96+
{
97+
"timestamp": "2026-04-01T09:00:01.000Z",
98+
"type": "response_item",
99+
"payload": {
100+
"type": "function_call",
101+
"name": "exec_command",
102+
"arguments": '{"cmd": "ls"}',
103+
"call_id": "x",
104+
},
105+
},
106+
# A non-function_call response_item.
107+
{
108+
"timestamp": "2026-04-01T09:00:02.000Z",
109+
"type": "response_item",
110+
"payload": {"type": "message", "role": "assistant"},
111+
},
112+
],
113+
)
114+
assert list(Codex()._parse_session_file(path, empty_ai_config)) == []
115+
116+
def test_tracks_cwd_and_model_across_turns(
117+
self, tmp_path: Path, empty_ai_config
118+
) -> None:
119+
path = tmp_path / "rollout.jsonl"
120+
_write_session(
121+
path,
122+
[
123+
_session_meta(cwd="/home/u/initial"),
124+
_turn_context(cwd="/home/u/turn1", model="gpt-5.5"),
125+
_function_call_entry(timestamp="2026-04-01T09:00:01.000Z"),
126+
_turn_context(cwd="/home/u/turn2", model="gpt-5.6"),
127+
_function_call_entry(timestamp="2026-04-01T09:00:02.000Z"),
128+
],
129+
)
130+
131+
events = list(Codex()._parse_session_file(path, empty_ai_config))
132+
133+
assert [(e.cwd, e.model) for e in events] == [
134+
("/home/u/turn1", "gpt-5.5"),
135+
("/home/u/turn2", "gpt-5.6"),
136+
]
137+
138+
def test_resolves_server_display_name_from_discovery(self, tmp_path: Path) -> None:
139+
config = AIDiscovery(
140+
user=UserInfo(hostname="h", username="u", machine_id="m"),
141+
servers=[
142+
MCPServer(
143+
name="LinearDisplay",
144+
configurations=[
145+
MCPConfiguration(
146+
name="linear",
147+
agent="codex",
148+
scope=MCPConfiguration.Scope.USER,
149+
transport=MCPConfiguration.Transport.HTTP,
150+
project=None,
151+
)
152+
],
153+
),
154+
],
155+
discovery_duration=0.0,
156+
)
157+
path = tmp_path / "rollout.jsonl"
158+
_write_session(path, [_session_meta(), _turn_context(), _function_call_entry()])
159+
160+
events = list(Codex()._parse_session_file(path, config))
161+
162+
assert events[0].server == "LinearDisplay"
163+
164+
165+
class TestCodexHistoryFiles:
166+
def test_globs_rollouts_under_dated_dirs(self, tmp_path: Path) -> None:
167+
sessions = tmp_path / ".codex" / "sessions"
168+
(sessions / "2026" / "04" / "01").mkdir(parents=True)
169+
(sessions / "2026" / "04" / "01" / "rollout-1.jsonl").write_text("{}\n")
170+
(sessions / "2026" / "04" / "02").mkdir(parents=True)
171+
(sessions / "2026" / "04" / "02" / "rollout-2.jsonl").write_text("{}\n")
172+
# Wrong depth — should be ignored.
173+
(sessions / "loose.jsonl").write_text("{}\n")
174+
# Wrong prefix — should be ignored.
175+
(sessions / "2026" / "04" / "02" / "other.jsonl").write_text("{}\n")
176+
177+
with patch(
178+
"ggshield.verticals.ai.agents.codex.get_user_home_dir",
179+
return_value=tmp_path,
180+
):
181+
files = sorted(Codex()._history_files())
182+
183+
assert [f.name for f in files] == ["rollout-1.jsonl", "rollout-2.jsonl"]

0 commit comments

Comments
 (0)