agenthacker 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,753 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 AgentHacker
3
+
4
+ """CloudClient — bridges the local SDK and the AgentHacker AWS backend.
5
+
6
+ When AGENTHACKER_API_KEY is set (or configure() is called explicitly), the SDK
7
+ automatically routes cloud features to the backend:
8
+
9
+ - IntentGuard.classify() consults Bedrock via /v1/intent/classify
10
+ - check_user_risk() reads/writes centralized Aurora via /v1/risk/{user_hash}
11
+ - log_firewall_event() / log_agent_invocation() send events to /v1/events
12
+
13
+ All HTTP calls are fail-open: a network error or non-200 response never
14
+ crashes the caller — the SDK silently falls back to its local behavior.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+ import re
22
+ from concurrent.futures import ThreadPoolExecutor
23
+ from typing import Any
24
+ from urllib.parse import quote
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ _DEFAULT_API_URL = "https://vhdzhows74.execute-api.us-west-2.amazonaws.com/prod"
29
+
30
+ # user_hash is produced by hash_email() as a lowercase hex string (8–64 chars).
31
+ # Reject anything outside this alphabet to prevent path traversal.
32
+ _USER_HASH_RE = re.compile(r"^[0-9a-f]{8,64}$")
33
+
34
+
35
+ def _safe_hash(user_hash: str) -> str:
36
+ """Validate and percent-encode user_hash for use in a URL path segment.
37
+
38
+ Raises ValueError for values that don't match the expected hex format so
39
+ that callers can log-and-skip rather than emit a malformed request.
40
+ """
41
+ if not _USER_HASH_RE.match(user_hash):
42
+ raise ValueError(
43
+ f"user_hash must be a lowercase hex string (8-64 chars), got {user_hash!r}"
44
+ )
45
+ return quote(user_hash, safe="")
46
+
47
+
48
+ # Default HTTP timeout (seconds) for cloud calls. Overridable via the
49
+ # AGENTHACKER_HTTP_TIMEOUT env var or a per-client ``timeout=``. The backend's
50
+ # Bedrock-judge endpoint can be slow on a cold start and cloud calls fail open,
51
+ # so a too-small timeout silently drops protection until the backend warms —
52
+ # raise it (or warm the backend at startup) for latency-variable deployments.
53
+ try:
54
+ _TIMEOUT_S = float(os.environ.get("AGENTHACKER_HTTP_TIMEOUT", "3"))
55
+ except ValueError:
56
+ _TIMEOUT_S = 3.0
57
+
58
+ _executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="cloud-client")
59
+
60
+ # Module-level singleton
61
+ _client: CloudClient | None = None
62
+
63
+
64
+ class CloudClient:
65
+ """HTTP client that calls the AgentHacker backend API.
66
+
67
+ Instantiate via configure() or use the module-level configure() helper.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ api_key: str,
73
+ api_url: str = _DEFAULT_API_URL,
74
+ *,
75
+ timeout: float | None = None,
76
+ ) -> None:
77
+ self._api_key = api_key
78
+ self._url = api_url.rstrip("/")
79
+ # Per-client HTTP timeout; defaults to the (env-configurable) module value.
80
+ self._timeout = float(timeout) if timeout is not None else _TIMEOUT_S
81
+ self._headers = {
82
+ "x-api-key": api_key,
83
+ "Content-Type": "application/json",
84
+ }
85
+ # Paths we've already warned about an auth failure for — so a rejected
86
+ # key produces one loud warning per endpoint, not a flood (or silence).
87
+ self._auth_warned: set[str] = set()
88
+ # Same one-warning-per-endpoint treatment for throttling (HTTP 429).
89
+ self._throttle_warned: set[str] = set()
90
+
91
+ # ── Intent classification ─────────────────────────────────────────
92
+
93
+ def classify_intent(
94
+ self,
95
+ message: str,
96
+ *,
97
+ session_id: str = "",
98
+ user_hash: str = "",
99
+ agent: str | None = None,
100
+ actor_role: str | None = None,
101
+ agent_intents: list[str] | None = None,
102
+ semantic_miss: bool = False,
103
+ llm_guard_injection: bool = False,
104
+ system_prompt_suffix: str | None = None,
105
+ active_task: str | None = None,
106
+ conversation_summary: str | None = None,
107
+ ) -> dict:
108
+ """POST /v1/intent/classify — returns {decision, confidence, threat_type, continuation, intents}.
109
+
110
+ intents is a list of {"text": str, "in_scope": bool} objects when
111
+ agent_intents is supplied; Bedrock scopes each split intent to the
112
+ declared list. Returns safe defaults on any error (fail-open).
113
+
114
+ semantic_miss=True signals that the caller's local semantic-similarity
115
+ gate was enabled and the message failed to match any known-good intent.
116
+ It is forwarded as a weak suspicion hint so the cloud judge scrutinizes
117
+ the message more carefully; it is never on its own grounds to block.
118
+
119
+ llm_guard_injection=True signals that a dedicated prompt-injection
120
+ classifier (LLM Guard) flagged this message upstream. It is a stronger
121
+ suspicion hint than semantic_miss, but is still advisory — the judge
122
+ makes the final call so the classifier never hard-refuses on its own.
123
+
124
+ system_prompt_suffix is appended to the Bedrock system prompt after
125
+ the base instructions and agent-intents block. Use it to add
126
+ domain-specific context (e.g. "Also block requests about competitor
127
+ products") without replacing the core security classification logic.
128
+
129
+ active_task / conversation_summary carry multi-turn context: when set,
130
+ the judge can treat a bare follow-up detail (a date, a name) as
131
+ continuing the in-progress task instead of an out-of-scope fragment, and
132
+ sets continuation=True in the response. Both are optional — omit them for
133
+ a stateless check.
134
+ """
135
+ body: dict = {
136
+ "message": message,
137
+ "session_id": session_id,
138
+ "user_hash": user_hash,
139
+ }
140
+ if agent:
141
+ body["agent"] = agent
142
+ if actor_role:
143
+ body["actor_role"] = actor_role
144
+ if agent_intents:
145
+ body["agent_intents"] = agent_intents
146
+ if semantic_miss:
147
+ body["semantic_miss"] = True
148
+ if llm_guard_injection:
149
+ body["llm_guard_injection"] = True
150
+ if system_prompt_suffix:
151
+ body["system_prompt_suffix"] = system_prompt_suffix
152
+ if active_task:
153
+ body["active_task"] = active_task
154
+ if conversation_summary:
155
+ body["conversation_summary"] = conversation_summary
156
+ return self._post("/v1/intent/classify", body) or {
157
+ "decision": "allow",
158
+ "confidence": 0.5,
159
+ "threat_type": None,
160
+ "continuation": False,
161
+ "intents": [],
162
+ }
163
+
164
+ # ── Output classification (Bedrock output judge) ──────────────────
165
+
166
+ def classify_output(
167
+ self,
168
+ output: str,
169
+ *,
170
+ session_id: str = "",
171
+ user_hash: str = "",
172
+ agent: str | None = None,
173
+ actor_role: str | None = None,
174
+ user_request: str | None = None,
175
+ system_prompt: str | None = None,
176
+ agent_intents: list[str] | None = None,
177
+ ) -> dict:
178
+ """POST /v1/output/classify — returns {decision, confidence, signal, reasoning}.
179
+
180
+ Asks the Bedrock judge whether an agent OUTPUT shows clear evidence of a
181
+ successful prompt injection (system-prompt leak, refusal-suppression,
182
+ persona break, intent-deviation, tool-abuse, data-exfiltration). Returns
183
+ safe defaults on any error (fail-open).
184
+
185
+ user_request and agent_intents give the judge the context it needs to
186
+ spot an output that answers a different task than the user asked.
187
+ system_prompt should be a short PERSONA DESCRIPTION, not the verbatim
188
+ secret prompt — verbatim-leak detection stays the job of the local
189
+ output-guard shingle check.
190
+ """
191
+ body: dict = {
192
+ "output": output,
193
+ "session_id": session_id,
194
+ "user_hash": user_hash,
195
+ }
196
+ if agent:
197
+ body["agent"] = agent
198
+ if actor_role:
199
+ body["actor_role"] = actor_role
200
+ if user_request:
201
+ body["user_request"] = user_request
202
+ if system_prompt:
203
+ body["system_prompt"] = system_prompt
204
+ if agent_intents:
205
+ body["agent_intents"] = agent_intents
206
+ return self._post("/v1/output/classify", body) or {
207
+ "decision": "allow",
208
+ "confidence": 0.5,
209
+ "signal": None,
210
+ "reasoning": None,
211
+ }
212
+
213
+ # ── Risk scoring ──────────────────────────────────────────────────
214
+
215
+ def get_risk_score(self, user_hash: str) -> dict | None:
216
+ """GET /v1/risk/{user_hash} — returns risk score dict or None on error."""
217
+ try:
218
+ safe = _safe_hash(user_hash)
219
+ except ValueError as exc:
220
+ logger.warning("get_risk_score: %s", exc)
221
+ return None
222
+ return self._get(f"/v1/risk/{safe}")
223
+
224
+ def record_invocation(
225
+ self,
226
+ user_hash: str,
227
+ *,
228
+ blocked: bool,
229
+ checkpoint: str | None = None,
230
+ rule_id: str | None = None,
231
+ latency_ms: float = 0.0,
232
+ tool_calls: int = 0,
233
+ tokens: int = 0,
234
+ session_id: str | None = None,
235
+ question_preview: str | None = None,
236
+ agent: str | None = None,
237
+ actor_role: str | None = None,
238
+ ) -> None:
239
+ """POST /v1/risk/{user_hash} — fire-and-forget invocation record."""
240
+ body: dict[str, Any] = {
241
+ "blocked": blocked,
242
+ "latency_ms": latency_ms,
243
+ "tool_calls": tool_calls,
244
+ "tokens": tokens,
245
+ }
246
+ if checkpoint:
247
+ body["checkpoint"] = checkpoint
248
+ if rule_id:
249
+ body["rule_id"] = rule_id
250
+ if session_id:
251
+ body["session_id"] = session_id
252
+ if question_preview:
253
+ body["question_preview"] = question_preview
254
+ if agent:
255
+ body["agent"] = agent
256
+ if actor_role:
257
+ body["actor_role"] = actor_role
258
+ try:
259
+ safe = _safe_hash(user_hash)
260
+ except ValueError as exc:
261
+ logger.warning("record_invocation: %s", exc)
262
+ return
263
+ self._post_bg(f"/v1/risk/{safe}", body)
264
+
265
+ # ── Event logging ─────────────────────────────────────────────────
266
+
267
+ def submit_events(self, batch: dict) -> None:
268
+ """POST /v1/events — fire-and-forget batch event log."""
269
+ self._post_bg("/v1/events", batch)
270
+
271
+ # ── Reports ───────────────────────────────────────────────────────
272
+
273
+ def generate_report(
274
+ self,
275
+ date_range: str = "30d",
276
+ agent: str | None = None,
277
+ ) -> dict | None:
278
+ """GET /v1/reports/generate — build and return a full security audit report.
279
+
280
+ Args:
281
+ date_range: Window to cover. One of "7d", "30d", "90d", "365d", "1y".
282
+ agent: Scope to a single agent name. Omit for a company-wide report.
283
+
284
+ Returns:
285
+ Dict with keys: report_id, generated_at, evidence_hash, s3_url,
286
+ data (full JSON bundle including narrative), html (styled HTML string).
287
+ Returns None on any error.
288
+ """
289
+ params: dict[str, str] = {"date_range": date_range}
290
+ if agent:
291
+ params["agent"] = agent
292
+ return self._get("/v1/reports/generate", params=params, timeout=90)
293
+
294
+ def list_reports(
295
+ self,
296
+ agent: str | None = None,
297
+ limit: int = 20,
298
+ ) -> list[dict] | None:
299
+ """GET /v1/reports — list past reports, most recent first.
300
+
301
+ Args:
302
+ agent: Filter to a specific agent. Omit for all.
303
+ limit: Max number of results (1–100).
304
+
305
+ Returns:
306
+ List of report metadata dicts, or None on error.
307
+ """
308
+ params: dict[str, str] = {"limit": str(limit)}
309
+ if agent:
310
+ params["agent"] = agent
311
+ result = self._get("/v1/reports", params=params)
312
+ return result.get("reports") if result else None
313
+
314
+ def get_report(self, report_id: str) -> dict | None:
315
+ """GET /v1/reports/{report_id} — retrieve a stored report by ID.
316
+
317
+ Args:
318
+ report_id: UUID of the report (returned by generate_report).
319
+
320
+ Returns:
321
+ Full report dict including data and html, or None on error.
322
+ """
323
+ safe_id = quote(report_id, safe="")
324
+ return self._get(f"/v1/reports/{safe_id}")
325
+
326
+ # ── Low-level HTTP helpers ────────────────────────────────────────
327
+
328
+ def _warn_auth_once(self, path: str, status: int) -> None:
329
+ """Emit a single loud warning when the backend rejects our key.
330
+
331
+ Cloud calls are fail-open by design — an outage must never take the
332
+ agent down. But a *configuration* failure (a key the URL won't accept)
333
+ is not an outage: silently failing open there means the firewall looks
334
+ installed while protecting nothing. So 401/403 gets a one-time WARNING
335
+ per endpoint, distinct from the debug-level noise of transient errors.
336
+ """
337
+ if path in self._auth_warned:
338
+ return
339
+ self._auth_warned.add(path)
340
+ logger.warning(
341
+ "AgentHacker: %s was rejected with HTTP %d (authentication/authorization "
342
+ "failed). The firewall is FAILING OPEN — requests are NOT being checked. "
343
+ "The API key may be invalid for this URL (%s) or not yet provisioned for it. "
344
+ "Verify AGENTHACKER_API_KEY / AGENTHACKER_API_URL, or call Firewall.preflight() "
345
+ "at startup to surface this before serving traffic.",
346
+ path,
347
+ status,
348
+ self._url,
349
+ )
350
+
351
+ def _warn_throttle_once(self, path: str) -> None:
352
+ """Warn once per endpoint when the backend throttles us (HTTP 429).
353
+
354
+ Like an auth failure, throttling is not a transient blip to swallow: the
355
+ cloud call fails open, so a rate-limited agent is silently *unchecked*
356
+ until the limit clears. Distinct from the debug-level noise of ordinary
357
+ transient errors so it is visible in logs.
358
+ """
359
+ if path in self._throttle_warned:
360
+ return
361
+ self._throttle_warned.add(path)
362
+ logger.warning(
363
+ "AgentHacker: %s was throttled with HTTP 429 (rate limited). The firewall "
364
+ "is FAILING OPEN — requests are NOT being checked until the limit clears. "
365
+ "Consider raising your usage-plan limit or adding client-side backoff.",
366
+ path,
367
+ )
368
+
369
+ def _post(self, path: str, body: dict) -> dict | None:
370
+ try:
371
+ import requests
372
+
373
+ resp = requests.post(
374
+ self._url + path,
375
+ json=body,
376
+ headers=self._headers,
377
+ timeout=self._timeout,
378
+ )
379
+ if resp.status_code < 300:
380
+ return resp.json()
381
+ if resp.status_code in (401, 403):
382
+ self._warn_auth_once(path, resp.status_code)
383
+ elif resp.status_code == 429:
384
+ self._warn_throttle_once(path)
385
+ else:
386
+ logger.debug("Cloud backend %s returned %d", path, resp.status_code)
387
+ except Exception as exc:
388
+ logger.debug("Cloud client POST %s failed: %s", path, exc)
389
+ return None
390
+
391
+ def _get(
392
+ self,
393
+ path: str,
394
+ params: dict[str, str] | None = None,
395
+ timeout: float | None = None,
396
+ ) -> dict | None:
397
+ try:
398
+ import requests
399
+
400
+ resp = requests.get(
401
+ self._url + path,
402
+ headers=self._headers,
403
+ params=params,
404
+ timeout=self._timeout if timeout is None else timeout,
405
+ )
406
+ if resp.status_code < 300:
407
+ return resp.json()
408
+ if resp.status_code in (401, 403):
409
+ self._warn_auth_once(path, resp.status_code)
410
+ elif resp.status_code == 429:
411
+ self._warn_throttle_once(path)
412
+ else:
413
+ logger.debug("Cloud backend %s returned %d", path, resp.status_code)
414
+ except Exception as exc:
415
+ logger.debug("Cloud client GET %s failed: %s", path, exc)
416
+ return None
417
+
418
+ def preflight(self, *, timeout: float | None = None) -> dict:
419
+ """Verify the API key + URL are usable, without failing open.
420
+
421
+ Sends a benign classification request to the live backend and reports
422
+ what actually happened, so misconfiguration can be caught at startup
423
+ instead of silently swallowed at request time. Never raises.
424
+
425
+ Returns a dict ``{"ok", "status", "url", "detail"}`` where ``ok`` is
426
+ True only on a 2xx response.
427
+ """
428
+ try:
429
+ import requests
430
+
431
+ resp = requests.post(
432
+ self._url + "/v1/intent/classify",
433
+ json={"message": "preflight", "session_id": "", "user_hash": ""},
434
+ headers=self._headers,
435
+ timeout=self._timeout if timeout is None else timeout,
436
+ )
437
+ if resp.status_code in (401, 403):
438
+ return {
439
+ "ok": False,
440
+ "status": resp.status_code,
441
+ "url": self._url,
442
+ "detail": "authentication failed — the key is not accepted by this URL",
443
+ }
444
+ if resp.status_code >= 300:
445
+ return {
446
+ "ok": False,
447
+ "status": resp.status_code,
448
+ "url": self._url,
449
+ "detail": f"unexpected status {resp.status_code}",
450
+ }
451
+ return {
452
+ "ok": True,
453
+ "status": resp.status_code,
454
+ "url": self._url,
455
+ "detail": "ok",
456
+ }
457
+ except Exception as exc:
458
+ return {
459
+ "ok": False,
460
+ "status": None,
461
+ "url": self._url,
462
+ "detail": f"backend unreachable: {exc}",
463
+ }
464
+
465
+ def _post_bg(self, path: str, body: dict) -> None:
466
+ """Submit HTTP POST in a background thread — never blocks the caller."""
467
+ _executor.submit(self._post, path, body)
468
+
469
+
470
+ # ── CloudStore — implements EventStore protocol ───────────────────────
471
+
472
+
473
+ class CloudStore:
474
+ """EventStore implementation that ships all events to the AWS backend.
475
+
476
+ Registered as the active store by setup_logging() when a cloud client
477
+ is configured, so all log_firewall_event() / log_agent_invocation()
478
+ calls automatically flow to Aurora.
479
+ """
480
+
481
+ def __init__(self, client: CloudClient) -> None:
482
+ self._client = client
483
+
484
+ def record_firewall_event(
485
+ self,
486
+ *,
487
+ checkpoint,
488
+ rule_id,
489
+ rule_name,
490
+ excerpt=None,
491
+ user_hash=None,
492
+ ip=None,
493
+ agent=None,
494
+ source="runtime",
495
+ actor_role=None,
496
+ invocation_id=None,
497
+ **_,
498
+ ) -> None:
499
+ self.submit_firewall_event(
500
+ checkpoint=checkpoint,
501
+ rule_id=rule_id,
502
+ rule_name=rule_name,
503
+ excerpt=excerpt,
504
+ user_hash=user_hash,
505
+ ip=ip,
506
+ agent=agent,
507
+ source=source,
508
+ actor_role=actor_role,
509
+ invocation_id=invocation_id,
510
+ )
511
+
512
+ def record_invocation(
513
+ self,
514
+ *,
515
+ invocation_id=None,
516
+ user_hash=None,
517
+ question_preview=None,
518
+ question_full=None,
519
+ blocked,
520
+ checkpoint=None,
521
+ rule_id=None,
522
+ tool_calls=0,
523
+ tokens=0,
524
+ latency_ms=0.0,
525
+ trace=None,
526
+ agent=None,
527
+ source="runtime",
528
+ actor_role=None,
529
+ session_id=None,
530
+ **_,
531
+ ) -> None:
532
+ self.submit_invocation(
533
+ invocation_id=invocation_id,
534
+ user_hash=user_hash,
535
+ question_preview=question_preview,
536
+ question_full=question_full,
537
+ blocked=blocked,
538
+ checkpoint=checkpoint,
539
+ rule_id=rule_id,
540
+ tool_calls=tool_calls,
541
+ tokens=tokens,
542
+ latency_ms=latency_ms,
543
+ trace=trace,
544
+ agent=agent,
545
+ source=source,
546
+ actor_role=actor_role,
547
+ session_id=session_id,
548
+ )
549
+
550
+ def submit_firewall_event(
551
+ self,
552
+ *,
553
+ checkpoint,
554
+ rule_id,
555
+ rule_name,
556
+ excerpt=None,
557
+ user_hash=None,
558
+ ip=None,
559
+ agent=None,
560
+ source="runtime",
561
+ actor_role=None,
562
+ invocation_id=None,
563
+ **_,
564
+ ) -> None:
565
+ self._client.submit_events(
566
+ {
567
+ "user_hash": user_hash,
568
+ "firewall_events": [
569
+ {
570
+ "checkpoint": checkpoint,
571
+ "rule_id": rule_id,
572
+ "rule_name": rule_name,
573
+ "excerpt": excerpt,
574
+ "ip": ip,
575
+ "agent": agent,
576
+ "source": source,
577
+ "actor_role": actor_role,
578
+ "invocation_id": invocation_id,
579
+ }
580
+ ],
581
+ }
582
+ )
583
+
584
+ def submit_invocation(
585
+ self,
586
+ *,
587
+ invocation_id=None,
588
+ user_hash=None,
589
+ question_preview=None,
590
+ question_full=None,
591
+ blocked,
592
+ checkpoint=None,
593
+ rule_id=None,
594
+ tool_calls=0,
595
+ tokens=0,
596
+ latency_ms=0.0,
597
+ trace=None,
598
+ agent=None,
599
+ source="runtime",
600
+ actor_role=None,
601
+ bedrock_decision=None,
602
+ bedrock_confidence=None,
603
+ session_id=None,
604
+ **_,
605
+ ) -> None:
606
+ batch: dict[str, Any] = {
607
+ "user_hash": user_hash,
608
+ "invocation": {
609
+ "invocation_id": invocation_id,
610
+ "question_preview": question_preview,
611
+ "blocked": blocked,
612
+ "checkpoint": checkpoint,
613
+ "rule_id": rule_id,
614
+ "tool_calls": tool_calls,
615
+ "tokens": tokens,
616
+ "latency_ms": latency_ms,
617
+ "trace": trace,
618
+ "agent": agent,
619
+ "source": source,
620
+ "actor_role": actor_role,
621
+ "bedrock_decision": bedrock_decision,
622
+ "bedrock_confidence": bedrock_confidence,
623
+ },
624
+ "firewall_events": [],
625
+ }
626
+ if session_id:
627
+ batch["session_id"] = session_id
628
+ if question_full and agent:
629
+ batch["s3_chat_line"] = {
630
+ "role": "user",
631
+ "content": question_full,
632
+ "agent": agent,
633
+ }
634
+ self._client.submit_events(batch)
635
+
636
+
637
+ # ── Module API ────────────────────────────────────────────────────────
638
+
639
+
640
+ def configure(
641
+ api_key: str | None = None,
642
+ api_url: str | None = None,
643
+ *,
644
+ timeout: float | None = None,
645
+ ) -> None:
646
+ """Configure the global cloud client.
647
+
648
+ Called automatically on SDK import if AGENTHACKER_API_KEY is set.
649
+ Can also be called explicitly in agent startup code.
650
+
651
+ Args:
652
+ api_key: AgentHacker API key. If None, reads AGENTHACKER_API_KEY env var.
653
+ api_url: Backend URL override. If None, reads the AGENTHACKER_API_URL env
654
+ var, then falls back to the production API Gateway URL. Set
655
+ AGENTHACKER_API_URL=http://localhost:8000 to point at a local backend.
656
+ """
657
+ global _client
658
+ key = api_key or os.environ.get("AGENTHACKER_API_KEY")
659
+ url = api_url or os.environ.get("AGENTHACKER_API_URL") or _DEFAULT_API_URL
660
+ if key:
661
+ _client = CloudClient(api_key=key, api_url=url, timeout=timeout)
662
+ logger.info("AgentHacker cloud client configured (url=%s)", url)
663
+ else:
664
+ _client = None
665
+
666
+
667
+ def get_client() -> CloudClient | None:
668
+ """Return the active cloud client, or None if not configured."""
669
+ return _client
670
+
671
+
672
+ def reset() -> None:
673
+ """Clear the active client. For test isolation only."""
674
+ global _client
675
+ _client = None
676
+
677
+
678
+ def generate_report(
679
+ date_range: str = "30d",
680
+ agent: str | None = None,
681
+ ) -> dict | None:
682
+ """Generate a security audit report via the AgentHacker backend.
683
+
684
+ Requires the cloud client to be configured (AGENTHACKER_API_KEY set or
685
+ configure() called). Returns None if not configured or on any error.
686
+
687
+ Args:
688
+ date_range: "7d", "30d", "90d", "365d", or "1y".
689
+ agent: Scope to one agent name; omit for a company-wide report.
690
+
691
+ Returns:
692
+ Dict with report_id, data (JSON bundle + narrative), and html
693
+ (self-contained styled HTML page ready for display). None on error.
694
+
695
+ Example::
696
+
697
+ from firewall_sdk import generate_report
698
+
699
+ result = generate_report(date_range="30d")
700
+ if result:
701
+ with open("report.html", "w") as f:
702
+ f.write(result["html"])
703
+ print("Report ID:", result["report_id"])
704
+ """
705
+ client = get_client()
706
+ if not client:
707
+ logger.warning(
708
+ "generate_report: cloud client not configured — set AGENTHACKER_API_KEY"
709
+ )
710
+ return None
711
+ return client.generate_report(date_range=date_range, agent=agent)
712
+
713
+
714
+ def list_reports(
715
+ agent: str | None = None,
716
+ limit: int = 20,
717
+ ) -> list[dict] | None:
718
+ """List past reports from the AgentHacker backend.
719
+
720
+ Args:
721
+ agent: Filter to a specific agent. Omit for all.
722
+ limit: Max results to return (1–100).
723
+
724
+ Returns:
725
+ List of report metadata dicts (report_id, agent, period_start,
726
+ period_end, generated_at, evidence_hash, narrative_source).
727
+ None if not configured or on error.
728
+ """
729
+ client = get_client()
730
+ if not client:
731
+ logger.warning(
732
+ "list_reports: cloud client not configured — set AGENTHACKER_API_KEY"
733
+ )
734
+ return None
735
+ return client.list_reports(agent=agent, limit=limit)
736
+
737
+
738
+ def get_report(report_id: str) -> dict | None:
739
+ """Retrieve a previously generated report by ID.
740
+
741
+ Args:
742
+ report_id: UUID returned by generate_report().
743
+
744
+ Returns:
745
+ Full report dict including data and html, or None on error.
746
+ """
747
+ client = get_client()
748
+ if not client:
749
+ logger.warning(
750
+ "get_report: cloud client not configured — set AGENTHACKER_API_KEY"
751
+ )
752
+ return None
753
+ return client.get_report(report_id)