Skip to content

Commit a29f94b

Browse files
amascia-ggpbeslin-ggclaude
committed
feat(ai): collect raw agent activity in ai discover --history
Ship full AI-agent session activity to GitGuardian, not just MCP tool calls. ggshield discovers each agent's on-disk transcripts / databases and sends the raw records verbatim; GitGuardian scans them and strips secrets server-side before storing them, so the client stays "dumb" and the data shape never depends on the ggshield version. Sources cover Claude Code, Codex, Cursor, Copilot CLI and VSCode (Copilot Chat). Records are batched (by count and by bytes) and posted to the agent-activity endpoint; the server deduplicates per record. Issue: NHI-1628 Co-Authored-By: Paul Beslin <paul.beslin-ext@gitguardian.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e1ee306 commit a29f94b

24 files changed

Lines changed: 1495 additions & 10 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
### Changed
2+
3+
- `ggshield ai discover --history` now also ships raw transcript/database records
4+
from supported AI agents to GitGuardian.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
### Changed
2+
3+
- `ggshield ai discover --history` now collects raw AI-agent activity records
4+
from Claude Code, Codex, Cursor, Copilot CLI and VSCode (Copilot Chat) and
5+
ships them verbatim to GitGuardian, which scans the content and strips
6+
secrets server-side before storing it.

ggshield/cmd/ai/discover.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"""
55

66
import json
7-
from typing import Any, Dict, List
7+
from typing import Any, Dict, List, Optional
88

99
import click
1010
from pygitguardian.models import AIDiscovery
@@ -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,
@@ -72,11 +76,13 @@ def discover_cmd(
7276
return
7377

7478
backfill_report = BackfillReport()
79+
activity_report: Optional[AgentActivityReport] = None
7580
try:
7681
config = submit_ai_discovery(client, config)
7782
save_discovery_cache(config)
7883
if scan_history:
7984
backfill_report = backfill_mcp_history(client, config)
85+
activity_report = collect_agent_activity(client)
8086
except Exception as exc:
8187
if "missing the following scope:" in str(exc):
8288
scope = str(exc).split("missing the following scope:")[1].strip()
@@ -86,15 +92,19 @@ def discover_cmd(
8692
ui.display_warning(f"Could not upload AI discovery to GitGuardian: {reason}")
8793

8894
# Summarize after sending to GIM, so we can benefit from its fixes.
89-
summary = _summarize_discovery(config, backfill_report)
95+
summary = _summarize_discovery(config, backfill_report, activity_report)
9096

9197
if use_json:
9298
click.echo(json.dumps(summary, indent=2))
9399
else:
94100
print_summary(summary)
95101

96102

97-
def _summarize_discovery(config: AIDiscovery, report: BackfillReport) -> Dict[str, Any]:
103+
def _summarize_discovery(
104+
config: AIDiscovery,
105+
report: BackfillReport,
106+
activity_report: Optional[AgentActivityReport] = None,
107+
) -> Dict[str, Any]:
98108
"""Summarize what we want to show of the discovery."""
99109
agent_names = set()
100110
servers = []
@@ -120,7 +130,7 @@ def _summarize_discovery(config: AIDiscovery, report: BackfillReport) -> Dict[st
120130
}
121131
)
122132
servers = sorted(servers, key=lambda x: x["name"])
123-
return {
133+
summary: Dict[str, Any] = {
124134
"agents": [AGENTS[name].display_name for name in agent_names],
125135
"servers": servers,
126136
"history": {
@@ -130,6 +140,14 @@ def _summarize_discovery(config: AIDiscovery, report: BackfillReport) -> Dict[st
130140
"skipped": report.skipped,
131141
},
132142
}
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,
149+
}
150+
return summary
133151

134152

135153
def print_summary(summary: Dict[str, Any]) -> None:
@@ -187,3 +205,19 @@ def print_summary(summary: Dict[str, Any]) -> None:
187205
f"({history['duplicates']:,} already known, "
188206
f"{history.get('skipped', 0):,} skipped)"
189207
)
208+
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")
217+
click.echo(
218+
f" • Recorded {agent_activity['ingested']:,} activity events "
219+
f"({agent_activity['duplicates']:,} already known)"
220+
)
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 and
2+
ship each record **raw** (verbatim) to the GitGuardian API, which scans the
3+
content and strips secrets server-side before storing it."""
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+
]
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Wire shape for raw history events sent to the GitGuardian API."""
2+
3+
from dataclasses import asdict, dataclass
4+
from typing import Dict
5+
6+
7+
@dataclass(frozen=True)
8+
class AgentActivityEvent:
9+
"""One raw record from an agent's transcript or database.
10+
11+
Fields:
12+
- ``agent_name``: short agent identifier matching ``Agent.name`` (e.g. ``"claude-code"``).
13+
- ``source_kind``: agent-scoped identifier for the file/table the record came from
14+
(e.g. ``"session_transcript"``, ``"composer_bubble"``).
15+
- ``source_path``: on-disk path relative to the agent's config dir, with variable
16+
parts (session UUIDs, workspace hashes) preserved.
17+
- ``record_offset``: stable string identifier of the record within its source file.
18+
Line index serialised as a string (``"0"``, ``"1"``, …) for JSONL and JSON files.
19+
For SQLite, the value(s) of the declared ``key_columns`` — single column: the
20+
column value; multiple columns: a JSON-encoded list. Subclasses may override
21+
:meth:`ActivitySource.record_offset` for non-trivial cases.
22+
- ``content``: the record serialised as a string. JSONL and JSON files: the raw
23+
line or file text verbatim. SQLite rows: the row dict serialised with
24+
``json.dumps`` (default) or a custom per-source filter.
25+
"""
26+
27+
agent_name: str
28+
source_kind: str
29+
source_path: str
30+
record_offset: str
31+
content: str
32+
33+
def to_dict(self) -> Dict[str, object]:
34+
return asdict(self)
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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+
# Records are shipped raw: GitGuardian scans and strips secrets
72+
# server-side before storing them, so the client stays "dumb".
73+
try:
74+
result = send_agent_activity_batch(client, list(buffer))
75+
except requests.exceptions.RequestException as exc:
76+
logger.warning(
77+
"agent_activity: batch of %d events failed: %s", len(buffer), exc
78+
)
79+
report.failed_batches += 1
80+
else:
81+
report.ingested += result.ingested
82+
report.duplicates += result.duplicates
83+
if not result.success:
84+
report.failed_batches += 1
85+
buffer.clear()
86+
buffer_bytes = 0
87+
88+
for agent in AGENTS.values():
89+
for event in agent.iter_agent_activity_events():
90+
buffer.append(event)
91+
buffer_bytes += len(event.content.encode("utf-8"))
92+
report.parsed += 1
93+
if len(buffer) >= BATCH_SIZE or buffer_bytes >= MAX_BATCH_BYTES:
94+
flush()
95+
flush()
96+
return report
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Generic readers for raw history sources (JSONL files, SQLite tables)."""
2+
3+
import logging
4+
import sqlite3
5+
from pathlib import Path
6+
from typing import Dict, Iterator, Sequence, Union
7+
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
def iter_jsonl(path: Path) -> Iterator[str]:
13+
"""Yield each non-blank line of a JSONL file as a raw string.
14+
15+
Returns an empty iterator on I/O failure.
16+
"""
17+
if not path.is_file():
18+
return
19+
try:
20+
with path.open("r", encoding="utf-8", errors="replace") as fp:
21+
for line in fp:
22+
line = line.rstrip("\n").rstrip("\r")
23+
if not line.strip():
24+
continue
25+
yield line
26+
except OSError:
27+
return
28+
29+
30+
def iter_sqlite_rows(
31+
db_path: Path,
32+
query: str,
33+
params: Sequence[Union[str, int, float, bytes, None]] = (),
34+
) -> Iterator[Dict[str, object]]:
35+
"""Yield each row of ``query`` as a ``{column: value}`` dict.
36+
37+
Missing DB/error → empty iterator.
38+
"""
39+
if not db_path.is_file():
40+
return
41+
try:
42+
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
43+
except sqlite3.Error as exc:
44+
logger.warning("iter_sqlite_rows: cannot open %s: %s", db_path, exc)
45+
return
46+
try:
47+
conn.row_factory = sqlite3.Row
48+
try:
49+
cursor = conn.execute(query, params)
50+
except sqlite3.Error as exc:
51+
logger.warning("iter_sqlite_rows: query failed on %s: %s", db_path, exc)
52+
return
53+
try:
54+
for row in cursor:
55+
yield {key: row[key] for key in row.keys()}
56+
except sqlite3.Error as exc:
57+
logger.warning("iter_sqlite_rows: read failed on %s: %s", db_path, exc)
58+
return
59+
finally:
60+
conn.close()

0 commit comments

Comments
 (0)