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.
- agenthacker-0.1.0.dist-info/METADATA +403 -0
- agenthacker-0.1.0.dist-info/RECORD +30 -0
- agenthacker-0.1.0.dist-info/WHEEL +4 -0
- agenthacker-0.1.0.dist-info/licenses/LICENSE +201 -0
- agenthacker-0.1.0.dist-info/licenses/NOTICE +6 -0
- firewall_sdk/__init__.py +100 -0
- firewall_sdk/agent_helpers.py +128 -0
- firewall_sdk/alignment_check.py +113 -0
- firewall_sdk/anomaly.py +462 -0
- firewall_sdk/client.py +676 -0
- firewall_sdk/cloud_client.py +753 -0
- firewall_sdk/constants.py +21 -0
- firewall_sdk/context_summarizer.py +164 -0
- firewall_sdk/event_store.py +660 -0
- firewall_sdk/features.py +128 -0
- firewall_sdk/intent_gate.py +325 -0
- firewall_sdk/intent_guard.py +373 -0
- firewall_sdk/intent_splitter.py +114 -0
- firewall_sdk/invariant.py +113 -0
- firewall_sdk/lang.py +311 -0
- firewall_sdk/llm_guard.py +318 -0
- firewall_sdk/llm_judge.py +92 -0
- firewall_sdk/logger.py +273 -0
- firewall_sdk/output_guard.py +150 -0
- firewall_sdk/py.typed +0 -0
- firewall_sdk/scan_engine.py +569 -0
- firewall_sdk/schemas.py +25 -0
- firewall_sdk/tool_guard.py +67 -0
- firewall_sdk/trace.py +68 -0
- firewall_sdk/translate_guard.py +188 -0
|
@@ -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)
|