Skip to content

Commit 985da1c

Browse files
amascia-ggclaude
andcommitted
feat(ai): attach reporting machine/user to agent activity
Send the discovered UserInfo (machine_id + hostname/username/email) with every agent-activity batch so GitGuardian can attribute the records and correlate them with the machine inventory (the machine_id matches the machine scan). Reuses the user already gathered for discovery. Issue: NHI-1628 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2c39516 commit 985da1c

3 files changed

Lines changed: 40 additions & 18 deletions

File tree

ggshield/cmd/ai/discover.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,9 @@ def discover_cmd(
8282
save_discovery_cache(config)
8383
if scan_history:
8484
backfill_report = backfill_mcp_history(client, config)
85-
activity_report = collect_agent_activity(client, AGENTS.values())
85+
activity_report = collect_agent_activity(
86+
client, config.user, AGENTS.values()
87+
)
8688
except Exception as exc:
8789
if "missing the following scope:" in str(exc):
8890
scope = str(exc).split("missing the following scope:")[1].strip()

ggshield/verticals/ai/agent_activity/orchestrator.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import requests
88
from pygitguardian import GGClient
9-
from pygitguardian.models import Detail
9+
from pygitguardian.models import Detail, UserInfo
1010

1111
from ggshield.verticals.ai.agent_activity.models import AgentActivityEvent
1212

@@ -40,13 +40,13 @@ class AgentActivityReport:
4040

4141

4242
def send_agent_activity_batch(
43-
client: GGClient, events: List[AgentActivityEvent]
43+
client: GGClient, events: List[AgentActivityEvent], user: UserInfo
4444
) -> AgentActivityBatchResult:
45-
"""Serialise events and submit them as one batch."""
45+
"""Serialise events and submit them as one batch (with the reporting user)."""
4646
if not events:
4747
return AgentActivityBatchResult(ingested=0, duplicates=0, success=True)
4848
payload = [e.to_dict() for e in events]
49-
response = client.send_agent_activity(payload)
49+
response = client.send_agent_activity(payload, user=user.to_dict())
5050
if isinstance(response, Detail):
5151
logger.warning("agent_activity: API returned an error: %s", response.detail)
5252
return AgentActivityBatchResult(ingested=0, duplicates=0, success=False)
@@ -58,9 +58,13 @@ def send_agent_activity_batch(
5858

5959

6060
def collect_agent_activity(
61-
client: GGClient, agents: "Iterable[Agent]"
61+
client: GGClient, user: UserInfo, agents: "Iterable[Agent]"
6262
) -> AgentActivityReport:
63-
"""Walk each agent's raw sources and ship records in BATCH_SIZE-event batches."""
63+
"""Walk each agent's raw sources and ship records in BATCH_SIZE-event batches.
64+
65+
user (the reporting machine/user) is attached to every batch so
66+
the server can attribute the activity and correlate it with the machine scan.
67+
"""
6468
report = AgentActivityReport()
6569
buffer: List[AgentActivityEvent] = []
6670
buffer_bytes = 0
@@ -70,7 +74,7 @@ def flush() -> None:
7074
if not buffer:
7175
return
7276
try:
73-
result = send_agent_activity_batch(client, list(buffer))
77+
result = send_agent_activity_batch(client, list(buffer), user)
7478
except requests.exceptions.RequestException as exc:
7579
logger.warning(
7680
"agent_activity: batch of %d events failed: %s", len(buffer), exc

tests/unit/verticals/ai/agent_activity/test_orchestrator.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from unittest.mock import MagicMock
22

33
import requests
4-
from pygitguardian.models import Detail
4+
from pygitguardian.models import Detail, UserInfo
55

66
from ggshield.verticals.ai.agent_activity.models import AgentActivityEvent
77
from ggshield.verticals.ai.agent_activity.orchestrator import (
@@ -12,6 +12,9 @@
1212
)
1313

1414

15+
_USER = UserInfo(hostname="dev-laptop", username="dev", machine_id="machine-001")
16+
17+
1518
def test_send_agent_activity_batch_serialises_and_calls_client() -> None:
1619
client = MagicMock()
1720
client.send_agent_activity.return_value = MagicMock(
@@ -33,7 +36,7 @@ def test_send_agent_activity_batch_serialises_and_calls_client() -> None:
3336
content='{"key": "bubbleId:abc:xyz", "value": "{"y": 2}"}',
3437
),
3538
]
36-
result = send_agent_activity_batch(client, events)
39+
result = send_agent_activity_batch(client, events, _USER)
3740
client.send_agent_activity.assert_called_once_with(
3841
[
3942
{
@@ -51,13 +54,14 @@ def test_send_agent_activity_batch_serialises_and_calls_client() -> None:
5154
"content": '{"key": "bubbleId:abc:xyz", "value": "{"y": 2}"}',
5255
},
5356
],
57+
user=_USER.to_dict(),
5458
)
5559
assert result.ingested == 2
5660

5761

5862
def test_send_agent_activity_batch_empty_list_short_circuits() -> None:
5963
client = MagicMock()
60-
result = send_agent_activity_batch(client, [])
64+
result = send_agent_activity_batch(client, [], _USER)
6165
client.send_agent_activity.assert_not_called()
6266
assert result.ingested == 0
6367

@@ -66,7 +70,7 @@ def test_send_agent_activity_batch_treats_detail_as_failure() -> None:
6670
"""An API error (Detail) is reported as a failed batch, not an ingest."""
6771
client = MagicMock()
6872
client.send_agent_activity.return_value = Detail("boom", status_code=500)
69-
result = send_agent_activity_batch(client, [_event("a", 1)])
73+
result = send_agent_activity_batch(client, [_event("a", 1)], _USER)
7074
assert result.success is False
7175
assert result.ingested == 0
7276
assert result.duplicates == 0
@@ -75,7 +79,7 @@ def test_send_agent_activity_batch_treats_detail_as_failure() -> None:
7579
def test_collect_agent_activity_counts_detail_response_as_failed_batch() -> None:
7680
client = MagicMock()
7781
client.send_agent_activity.return_value = Detail("boom", status_code=500)
78-
report = collect_agent_activity(client, [_make_agent("a", [_event("a", 1)])])
82+
report = collect_agent_activity(client, _USER, [_make_agent("a", [_event("a", 1)])])
7983
assert report.parsed == 1
8084
assert report.ingested == 0
8185
assert report.failed_batches == 1
@@ -98,6 +102,18 @@ def _event(agent_name: str, i: int) -> AgentActivityEvent:
98102
)
99103

100104

105+
def test_collect_agent_activity_attaches_user_to_each_batch() -> None:
106+
client = MagicMock()
107+
client.send_agent_activity.return_value = MagicMock(
108+
success=True, ingested=1, duplicates=0
109+
)
110+
collect_agent_activity(client, _USER, [_make_agent("a", [_event("a", 1)])])
111+
112+
_, kwargs = client.send_agent_activity.call_args
113+
assert kwargs["user"] == _USER.to_dict()
114+
assert kwargs["user"]["machine_id"] == "machine-001"
115+
116+
101117
def test_collect_agent_activity_batches_in_chunks_of_batch_size() -> None:
102118
"""An agent yielding more than BATCH_SIZE events triggers multiple sends."""
103119
events = [_event("a", i) for i in range(BATCH_SIZE + 3)]
@@ -107,7 +123,7 @@ def test_collect_agent_activity_batches_in_chunks_of_batch_size() -> None:
107123
success=True, ingested=BATCH_SIZE, duplicates=0
108124
)
109125

110-
report = collect_agent_activity(client, [agent])
126+
report = collect_agent_activity(client, _USER, [agent])
111127

112128
assert client.send_agent_activity.call_count == 2
113129
assert report.parsed == BATCH_SIZE + 3
@@ -122,7 +138,7 @@ def test_collect_agent_activity_aggregates_per_agent_counts() -> None:
122138
)
123139

124140
report = collect_agent_activity(
125-
client, [_make_agent("a", events_a), _make_agent("b", events_b)]
141+
client, _USER, [_make_agent("a", events_a), _make_agent("b", events_b)]
126142
)
127143

128144
assert report.parsed == 2
@@ -131,7 +147,7 @@ def test_collect_agent_activity_aggregates_per_agent_counts() -> None:
131147

132148
def test_collect_agent_activity_handles_empty_agents() -> None:
133149
client = MagicMock()
134-
report = collect_agent_activity(client, [_make_agent("a", [])])
150+
report = collect_agent_activity(client, _USER, [_make_agent("a", [])])
135151
client.send_agent_activity.assert_not_called()
136152
assert report.parsed == 0
137153

@@ -152,7 +168,7 @@ def test_collect_agent_activity_flushes_on_byte_threshold() -> None:
152168
client = MagicMock()
153169
client.send_agent_activity.return_value = MagicMock(ingested=1, duplicates=0)
154170

155-
report = collect_agent_activity(client, [_make_agent("a", events)])
171+
report = collect_agent_activity(client, _USER, [_make_agent("a", events)])
156172

157173
# 3 oversized events => 3 separate sends, none waiting for the 500 count.
158174
assert client.send_agent_activity.call_count == 3
@@ -163,7 +179,7 @@ def test_collect_agent_activity_counts_network_error_as_failed_batch() -> None:
163179
"""A network error while sending a batch is counted as a failed batch."""
164180
client = MagicMock()
165181
client.send_agent_activity.side_effect = requests.exceptions.ConnectionError("down")
166-
report = collect_agent_activity(client, [_make_agent("a", [_event("a", 1)])])
182+
report = collect_agent_activity(client, _USER, [_make_agent("a", [_event("a", 1)])])
167183

168184
assert report.parsed == 1
169185
assert report.ingested == 0

0 commit comments

Comments
 (0)