Skip to content

Commit 6013a56

Browse files
amascia-ggclaude
andcommitted
feat(ai): collect agent activity (raw lines + client redaction) in ai discover --history
Ship full AI-agent session activity (not just MCP calls) to a raw staging table, keeping the client "dumb" and storing no detected secrets: - ggshield ships the agent's RAW transcript lines / DB rows verbatim — the data shape never depends on the ggshield version (no per-agent field extraction). - Before sending, each batch is scanned via the GitGuardian secret-scan API (multi_content_scan); detected secret spans are redacted client-side and home paths anonymised. Fail-closed: a batch that can't be scanned is dropped, never shipped. - Per-record size cap + byte-batching; re-scan every run (offset-skip is a follow-up). - Review fixes to the framework: GGClient typing + Detail handling in the orchestrator; POSIX source paths. - Bumps pygitguardian to send_agent_activity (GitGuardian/py-gitguardian#175). Aligns with the design doc's staging → canonical structure; the canonical typed table + per-agent adapters are a follow-up MR. Builds on #1244 / #1257. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 372bf46 commit 6013a56

26 files changed

Lines changed: 1032 additions & 356 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: 33 additions & 20 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,13 @@ 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,
149+
"redaction_failures": activity_report.redaction_failures,
146150
}
147151
return summary
148152

@@ -203,15 +207,24 @@ def print_summary(summary: Dict[str, Any]) -> None:
203207
f"{history.get('skipped', 0):,} skipped)"
204208
)
205209

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")
210+
agent_activity = summary.get("agent_activity")
211+
# Only surface the agent-activity block when there is something to report;
212+
# otherwise every `--history` run prints an all-zeros section.
213+
if agent_activity is not None and (
214+
agent_activity["parsed"] or agent_activity.get("failed_batches", 0)
215+
):
216+
click.echo(f"{format_text('Collecting agent activity…', STYLE['key'])}")
217+
click.echo(f" • Parsed {agent_activity['parsed']:,} activity events")
210218
click.echo(
211-
f" • Recorded {raw_history['ingested']:,} raw events "
212-
f"({raw_history['duplicates']:,} already known)"
219+
f" • Recorded {agent_activity['ingested']:,} activity events "
220+
f"({agent_activity['duplicates']:,} already known)"
213221
)
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']:,}"
222+
if agent_activity.get("failed_batches", 0) > 0:
223+
label = format_text("Failed batches:", STYLE["detector_line_start"])
224+
click.echo(f" • {label} {agent_activity['failed_batches']:,}")
225+
if agent_activity.get("redaction_failures", 0) > 0:
226+
label = format_text(
227+
"Batches dropped (redaction unavailable):",
228+
STYLE["detector_line_start"],
217229
)
230+
click.echo(f" • {label} {agent_activity['redaction_failures']:,}")
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: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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+
from ggshield.verticals.ai.agent_activity.redaction import RedactionError, redact_batch
13+
14+
15+
logger = logging.getLogger(__name__)
16+
17+
BATCH_SIZE = 500
18+
19+
# Flush a batch once it reaches BATCH_SIZE events OR this many bytes of content,
20+
# whichever comes first, so a few large records can't produce a huge request.
21+
MAX_BATCH_BYTES = 5 * 1024 * 1024
22+
23+
24+
@dataclass
25+
class AgentActivityBatchResult:
26+
ingested: int
27+
duplicates: int
28+
success: bool
29+
30+
31+
@dataclass
32+
class AgentActivityReport:
33+
parsed: int = 0
34+
ingested: int = 0
35+
duplicates: int = 0
36+
failed_batches: int = 0
37+
redaction_failures: int = 0
38+
39+
40+
def send_agent_activity_batch(
41+
client: GGClient, events: List[AgentActivityEvent]
42+
) -> AgentActivityBatchResult:
43+
"""Serialise ``events`` and submit them as one batch."""
44+
if not events:
45+
return AgentActivityBatchResult(ingested=0, duplicates=0, success=True)
46+
payload = [e.to_dict() for e in events]
47+
response = client.send_agent_activity(payload)
48+
if isinstance(response, Detail):
49+
logger.warning("agent_activity: API returned an error: %s", response.detail)
50+
return AgentActivityBatchResult(ingested=0, duplicates=0, success=False)
51+
return AgentActivityBatchResult(
52+
ingested=response.ingested,
53+
duplicates=response.duplicates,
54+
success=True,
55+
)
56+
57+
58+
def collect_agent_activity(client: GGClient) -> AgentActivityReport:
59+
"""Walk every supported agent's raw sources and ship records in BATCH_SIZE-event batches."""
60+
# Imported lazily: agent modules register agent-activity sources that subclass
61+
# ``ActivitySource``, so importing ``AGENTS`` at module load would create a
62+
# cycle (agents -> agent_activity -> orchestrator -> agents).
63+
from ggshield.verticals.ai.agents import AGENTS
64+
65+
report = AgentActivityReport()
66+
buffer: List[AgentActivityEvent] = []
67+
buffer_bytes = 0
68+
69+
def flush() -> None:
70+
nonlocal buffer_bytes
71+
if not buffer:
72+
return
73+
try:
74+
# Fail-closed: scan + redact (and anonymise home paths) before
75+
# anything is sent. If the scan fails we drop the batch rather than
76+
# ship un-scanned content.
77+
redacted = redact_batch(client, list(buffer))
78+
except RedactionError as exc:
79+
logger.warning(
80+
"agent_activity: redaction failed for %d events, dropping batch: %s",
81+
len(buffer),
82+
exc,
83+
)
84+
report.redaction_failures += 1
85+
report.failed_batches += 1
86+
else:
87+
try:
88+
result = send_agent_activity_batch(client, redacted)
89+
except requests.exceptions.RequestException as exc:
90+
logger.warning(
91+
"agent_activity: batch of %d events failed: %s", len(buffer), exc
92+
)
93+
report.failed_batches += 1
94+
else:
95+
report.ingested += result.ingested
96+
report.duplicates += result.duplicates
97+
if not result.success:
98+
report.failed_batches += 1
99+
buffer.clear()
100+
buffer_bytes = 0
101+
102+
for agent in AGENTS.values():
103+
for event in agent.iter_agent_activity_events():
104+
buffer.append(event)
105+
buffer_bytes += len(event.content.encode("utf-8"))
106+
report.parsed += 1
107+
if len(buffer) >= BATCH_SIZE or buffer_bytes >= MAX_BATCH_BYTES:
108+
flush()
109+
flush()
110+
return report
File renamed without changes.

0 commit comments

Comments
 (0)