Skip to content

Commit 7ce6a36

Browse files
amascia-ggclaude
andcommitted
feat(ai): collect agent-activity metadata in ai discover --history
Extend the AI-agent history framework to ship per-event *usage metadata* (not just MCP PreToolUse calls) to GitGuardian, powering usage dashboards. - Per-agent sources (Claude, Codex, Cursor) walk transcripts/SQLite and, in a fail-closed `serialize()`, emit only an allow-list of safe structured fields (event type, tool name, model, timestamps, …). Free text — prompts, command strings, tool inputs/outputs, file contents — is never sent, so no secret or PII ever leaves the machine. - Home paths are anonymised (`/Users/x` -> `~`); per-record size cap + byte-batching bound payloads. - Base `ActivitySource.serialize` raises by default: a source can never accidentally ship raw content. - Renamed the package raw_history -> agent_activity (it is metadata, not raw). - Review fixes to the original framework: `GGClient` typing + `Detail` error handling in the orchestrator, and the home-path leak fix. - Bump pygitguardian to the commit adding `send_agent_activity()` (GitGuardian/py-gitguardian#175); re-pin once that merges. Builds on #1244 / #1257 (MCP history framework). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 372bf46 commit 7ce6a36

25 files changed

Lines changed: 1083 additions & 531 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
### Changed
2+
3+
- `ggshield ai discover --history` now ships per-event AI-agent **usage
4+
metadata** (event type, tool name, model, timestamps) from Claude Code,
5+
Codex and Cursor — not just MCP tool calls. Only an allow-list of safe
6+
fields is sent: prompts, command strings, tool inputs/outputs and file
7+
contents never leave the machine.

ggshield/cmd/ai/discover.py

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
from ggshield.core.client import create_client_from_config
1616
from ggshield.core.errors import APIKeyCheckError, UnknownInstanceError
1717
from ggshield.core.text_utils import STYLE, format_text, pluralize
18+
from ggshield.verticals.ai.agent_activity import (
19+
AgentActivityReport,
20+
collect_agent_activity,
21+
)
1822
from ggshield.verticals.ai.agents import AGENTS
1923
from ggshield.verticals.ai.discovery import (
2024
discover_ai_configuration,
@@ -23,7 +27,6 @@
2327
)
2428
from ggshield.verticals.ai.history import BackfillReport, backfill_mcp_history
2529
from ggshield.verticals.ai.models import Scope
26-
from ggshield.verticals.ai.raw_history import RawHistoryReport, collect_raw_history
2730

2831

2932
@click.command(name="discover")
@@ -73,13 +76,13 @@ def discover_cmd(
7376
return
7477

7578
backfill_report = BackfillReport()
76-
raw_report: Optional[RawHistoryReport] = None
79+
activity_report: Optional[AgentActivityReport] = None
7780
try:
7881
config = submit_ai_discovery(client, config)
7982
save_discovery_cache(config)
8083
if scan_history:
8184
backfill_report = backfill_mcp_history(client, config)
82-
raw_report = collect_raw_history(client)
85+
activity_report = collect_agent_activity(client)
8386
except Exception as exc:
8487
if "missing the following scope:" in str(exc):
8588
scope = str(exc).split("missing the following scope:")[1].strip()
@@ -89,7 +92,7 @@ def discover_cmd(
8992
ui.display_warning(f"Could not upload AI discovery to GitGuardian: {reason}")
9093

9194
# Summarize after sending to GIM, so we can benefit from its fixes.
92-
summary = _summarize_discovery(config, backfill_report, raw_report)
95+
summary = _summarize_discovery(config, backfill_report, activity_report)
9396

9497
if use_json:
9598
click.echo(json.dumps(summary, indent=2))
@@ -100,7 +103,7 @@ def discover_cmd(
100103
def _summarize_discovery(
101104
config: AIDiscovery,
102105
report: BackfillReport,
103-
raw_report: Optional[RawHistoryReport] = None,
106+
activity_report: Optional[AgentActivityReport] = None,
104107
) -> Dict[str, Any]:
105108
"""Summarize what we want to show of the discovery."""
106109
agent_names = set()
@@ -137,12 +140,12 @@ def _summarize_discovery(
137140
"skipped": report.skipped,
138141
},
139142
}
140-
if raw_report is not None:
141-
summary["raw_history"] = {
142-
"parsed": raw_report.parsed,
143-
"ingested": raw_report.ingested,
144-
"duplicates": raw_report.duplicates,
145-
"failed_batches": raw_report.failed_batches,
143+
if activity_report is not None:
144+
summary["agent_activity"] = {
145+
"parsed": activity_report.parsed,
146+
"ingested": activity_report.ingested,
147+
"duplicates": activity_report.duplicates,
148+
"failed_batches": activity_report.failed_batches,
146149
}
147150
return summary
148151

@@ -203,15 +206,18 @@ def print_summary(summary: Dict[str, Any]) -> None:
203206
f"{history.get('skipped', 0):,} skipped)"
204207
)
205208

206-
raw_history = summary.get("raw_history")
207-
if raw_history is not None:
208-
click.echo(f"{format_text('Collecting raw agent history…', STYLE['key'])}")
209-
click.echo(f" • Parsed {raw_history['parsed']:,} raw events")
209+
agent_activity = summary.get("agent_activity")
210+
# Only surface the agent-activity block when there is something to report;
211+
# otherwise every `--history` run prints an all-zeros section.
212+
if agent_activity is not None and (
213+
agent_activity["parsed"] or agent_activity.get("failed_batches", 0)
214+
):
215+
click.echo(f"{format_text('Collecting agent activity…', STYLE['key'])}")
216+
click.echo(f" • Parsed {agent_activity['parsed']:,} activity events")
210217
click.echo(
211-
f" • Recorded {raw_history['ingested']:,} raw events "
212-
f"({raw_history['duplicates']:,} already known)"
218+
f" • Recorded {agent_activity['ingested']:,} activity events "
219+
f"({agent_activity['duplicates']:,} already known)"
213220
)
214-
if raw_history.get("failed_batches", 0) > 0:
215-
click.echo(
216-
f" • {format_text('Failed batches:', STYLE['detector_line_start'])} {raw_history['failed_batches']:,}"
217-
)
221+
if agent_activity.get("failed_batches", 0) > 0:
222+
label = format_text("Failed batches:", STYLE["detector_line_start"])
223+
click.echo(f" • {label} {agent_activity['failed_batches']:,}")
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""AI-agent activity collection: read transcript files and SQLite databases,
2+
extract allow-listed usage metadata per source (never raw free text), and ship
3+
it to the GitGuardian API."""
4+
5+
from ggshield.verticals.ai.agent_activity.models import AgentActivityEvent
6+
from ggshield.verticals.ai.agent_activity.orchestrator import (
7+
BATCH_SIZE,
8+
AgentActivityBatchResult,
9+
AgentActivityReport,
10+
collect_agent_activity,
11+
send_agent_activity_batch,
12+
)
13+
from ggshield.verticals.ai.agent_activity.readers import iter_jsonl, iter_sqlite_rows
14+
from ggshield.verticals.ai.agent_activity.sources import (
15+
ActivitySource,
16+
JSONActivitySource,
17+
JSONLActivitySource,
18+
SQLiteActivitySource,
19+
)
20+
21+
22+
__all__ = [
23+
"BATCH_SIZE",
24+
"ActivitySource",
25+
"JSONActivitySource",
26+
"JSONLActivitySource",
27+
"AgentActivityBatchResult",
28+
"AgentActivityEvent",
29+
"AgentActivityReport",
30+
"SQLiteActivitySource",
31+
"collect_agent_activity",
32+
"iter_jsonl",
33+
"iter_sqlite_rows",
34+
"send_agent_activity_batch",
35+
]

ggshield/verticals/ai/raw_history/models.py renamed to ggshield/verticals/ai/agent_activity/models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66

77
@dataclass(frozen=True)
8-
class RawHistoryEvent:
8+
class AgentActivityEvent:
99
"""One raw record from an agent's transcript or database.
1010
1111
Fields:
@@ -18,7 +18,7 @@ class RawHistoryEvent:
1818
Line index serialised as a string (``"0"``, ``"1"``, …) for JSONL and JSON files.
1919
For SQLite, the value(s) of the declared ``key_columns`` — single column: the
2020
column value; multiple columns: a JSON-encoded list. Subclasses may override
21-
:meth:`HistorySource.record_offset` for non-trivial cases.
21+
:meth:`ActivitySource.record_offset` for non-trivial cases.
2222
- ``content``: the record serialised as a string. JSONL and JSON files: the raw
2323
line or file text verbatim. SQLite rows: the row dict serialised with
2424
``json.dumps`` (default) or a custom per-source filter.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Orchestrate agent-activity collection across agents and ship batches to the API."""
2+
3+
import logging
4+
from dataclasses import dataclass
5+
from typing import List
6+
7+
import requests
8+
from pygitguardian import GGClient
9+
from pygitguardian.models import Detail
10+
11+
from ggshield.verticals.ai.agent_activity.models import AgentActivityEvent
12+
13+
14+
logger = logging.getLogger(__name__)
15+
16+
BATCH_SIZE = 500
17+
18+
# Flush a batch once it reaches BATCH_SIZE events OR this many bytes of content,
19+
# whichever comes first, so a few large records can't produce a huge request.
20+
MAX_BATCH_BYTES = 5 * 1024 * 1024
21+
22+
23+
@dataclass
24+
class AgentActivityBatchResult:
25+
ingested: int
26+
duplicates: int
27+
success: bool
28+
29+
30+
@dataclass
31+
class AgentActivityReport:
32+
parsed: int = 0
33+
ingested: int = 0
34+
duplicates: int = 0
35+
failed_batches: int = 0
36+
37+
38+
def send_agent_activity_batch(
39+
client: GGClient, events: List[AgentActivityEvent]
40+
) -> AgentActivityBatchResult:
41+
"""Serialise ``events`` and submit them as one batch."""
42+
if not events:
43+
return AgentActivityBatchResult(ingested=0, duplicates=0, success=True)
44+
payload = [e.to_dict() for e in events]
45+
response = client.send_agent_activity(payload)
46+
if isinstance(response, Detail):
47+
logger.warning("agent_activity: API returned an error: %s", response.detail)
48+
return AgentActivityBatchResult(ingested=0, duplicates=0, success=False)
49+
return AgentActivityBatchResult(
50+
ingested=response.ingested,
51+
duplicates=response.duplicates,
52+
success=True,
53+
)
54+
55+
56+
def collect_agent_activity(client: GGClient) -> AgentActivityReport:
57+
"""Walk every supported agent's raw sources and ship records in BATCH_SIZE-event batches."""
58+
# Imported lazily: agent modules register agent-activity sources that subclass
59+
# ``ActivitySource``, so importing ``AGENTS`` at module load would create a
60+
# cycle (agents -> agent_activity -> orchestrator -> agents).
61+
from ggshield.verticals.ai.agents import AGENTS
62+
63+
report = AgentActivityReport()
64+
buffer: List[AgentActivityEvent] = []
65+
buffer_bytes = 0
66+
67+
def flush() -> None:
68+
nonlocal buffer_bytes
69+
if not buffer:
70+
return
71+
try:
72+
result = send_agent_activity_batch(client, list(buffer))
73+
except requests.exceptions.RequestException as exc:
74+
logger.warning(
75+
"agent_activity: batch of %d events failed: %s", len(buffer), exc
76+
)
77+
report.failed_batches += 1
78+
else:
79+
report.ingested += result.ingested
80+
report.duplicates += result.duplicates
81+
if not result.success:
82+
report.failed_batches += 1
83+
buffer.clear()
84+
buffer_bytes = 0
85+
86+
for agent in AGENTS.values():
87+
for event in agent.iter_agent_activity_events():
88+
buffer.append(event)
89+
buffer_bytes += len(event.content.encode("utf-8"))
90+
report.parsed += 1
91+
if len(buffer) >= BATCH_SIZE or buffer_bytes >= MAX_BATCH_BYTES:
92+
flush()
93+
flush()
94+
return report
File renamed without changes.

0 commit comments

Comments
 (0)