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,660 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2026 AgentHacker
|
|
3
|
+
|
|
4
|
+
"""EventStore Protocol, NullStore, and PostgresStore implementations.
|
|
5
|
+
|
|
6
|
+
Defines the contract for persisting firewall events and agent invocations.
|
|
7
|
+
The SDK ships NullStore (no-op default) and PostgresStore (requires psycopg2).
|
|
8
|
+
Consumers can implement their own store (e.g. DynamoDB) without touching SDK code.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import uuid
|
|
15
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from typing import Any, Protocol, runtime_checkable
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@runtime_checkable
|
|
23
|
+
class EventStore(Protocol):
|
|
24
|
+
"""Contract for persisting firewall telemetry.
|
|
25
|
+
|
|
26
|
+
Two pairs of methods:
|
|
27
|
+
- record_* — synchronous, for scripts/tests/batch operations.
|
|
28
|
+
- submit_* — non-blocking fire-and-forget, for the logger in async routes.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def record_firewall_event(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
timestamp: datetime,
|
|
35
|
+
checkpoint: str,
|
|
36
|
+
rule_id: str,
|
|
37
|
+
rule_name: str,
|
|
38
|
+
excerpt: str | None = None,
|
|
39
|
+
user_hash: str | None = None,
|
|
40
|
+
ip: str | None = None,
|
|
41
|
+
agent: str | None = None,
|
|
42
|
+
source: str = "runtime",
|
|
43
|
+
actor_role: str | None = None,
|
|
44
|
+
invocation_id: str | None = None,
|
|
45
|
+
) -> None: ...
|
|
46
|
+
|
|
47
|
+
def record_invocation(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
invocation_id: str,
|
|
51
|
+
timestamp: datetime,
|
|
52
|
+
user_hash: str | None = None,
|
|
53
|
+
question_preview: str | None = None,
|
|
54
|
+
blocked: bool,
|
|
55
|
+
checkpoint: str | None = None,
|
|
56
|
+
rule_id: str | None = None,
|
|
57
|
+
tool_calls: int = 0,
|
|
58
|
+
tokens: int = 0,
|
|
59
|
+
latency_ms: float = 0.0,
|
|
60
|
+
trace: Any = None,
|
|
61
|
+
agent: str | None = None,
|
|
62
|
+
source: str = "runtime",
|
|
63
|
+
actor_role: str | None = None,
|
|
64
|
+
) -> None: ...
|
|
65
|
+
|
|
66
|
+
def submit_firewall_event(
|
|
67
|
+
self,
|
|
68
|
+
*,
|
|
69
|
+
timestamp: datetime,
|
|
70
|
+
checkpoint: str,
|
|
71
|
+
rule_id: str,
|
|
72
|
+
rule_name: str,
|
|
73
|
+
excerpt: str | None = None,
|
|
74
|
+
user_hash: str | None = None,
|
|
75
|
+
ip: str | None = None,
|
|
76
|
+
agent: str | None = None,
|
|
77
|
+
source: str = "runtime",
|
|
78
|
+
actor_role: str | None = None,
|
|
79
|
+
invocation_id: str | None = None,
|
|
80
|
+
) -> None: ...
|
|
81
|
+
|
|
82
|
+
def submit_invocation(
|
|
83
|
+
self,
|
|
84
|
+
*,
|
|
85
|
+
invocation_id: str,
|
|
86
|
+
timestamp: datetime,
|
|
87
|
+
user_hash: str | None = None,
|
|
88
|
+
question_preview: str | None = None,
|
|
89
|
+
blocked: bool,
|
|
90
|
+
checkpoint: str | None = None,
|
|
91
|
+
rule_id: str | None = None,
|
|
92
|
+
tool_calls: int = 0,
|
|
93
|
+
tokens: int = 0,
|
|
94
|
+
latency_ms: float = 0.0,
|
|
95
|
+
trace: Any = None,
|
|
96
|
+
agent: str | None = None,
|
|
97
|
+
source: str = "runtime",
|
|
98
|
+
actor_role: str | None = None,
|
|
99
|
+
) -> None: ...
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class NullStore:
|
|
103
|
+
"""Default store — all methods are no-ops. Zero overhead, no persistence."""
|
|
104
|
+
|
|
105
|
+
def record_firewall_event(self, **kwargs: Any) -> None:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
def record_invocation(self, **kwargs: Any) -> None:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
def submit_firewall_event(self, **kwargs: Any) -> None:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
def submit_invocation(self, **kwargs: Any) -> None:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
def insert_batch_firewall_events(self, rows: list[dict]) -> None:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
def insert_batch_invocations(self, rows: list[dict]) -> None:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
def upsert_risk_score(self, risk_score: Any) -> None:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
def get_risk_score(self, user_hash: str) -> None:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
def pardon_user(self, user_hash: str, hours: int = 1) -> None:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ── SQL ──────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
_CREATE_FIREWALL_EVENTS = """
|
|
136
|
+
CREATE TABLE IF NOT EXISTS firewall_events (
|
|
137
|
+
id UUID PRIMARY KEY,
|
|
138
|
+
invocation_id UUID,
|
|
139
|
+
timestamp TIMESTAMPTZ NOT NULL,
|
|
140
|
+
checkpoint TEXT NOT NULL,
|
|
141
|
+
rule_id TEXT NOT NULL,
|
|
142
|
+
rule_name TEXT NOT NULL,
|
|
143
|
+
excerpt TEXT,
|
|
144
|
+
user_hash TEXT,
|
|
145
|
+
ip TEXT,
|
|
146
|
+
agent TEXT,
|
|
147
|
+
source TEXT NOT NULL DEFAULT 'runtime',
|
|
148
|
+
actor_role TEXT
|
|
149
|
+
)
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
_CREATE_INVOCATIONS = """
|
|
153
|
+
CREATE TABLE IF NOT EXISTS invocations (
|
|
154
|
+
id UUID PRIMARY KEY,
|
|
155
|
+
timestamp TIMESTAMPTZ NOT NULL,
|
|
156
|
+
user_hash TEXT,
|
|
157
|
+
question_preview TEXT,
|
|
158
|
+
blocked BOOLEAN NOT NULL,
|
|
159
|
+
checkpoint TEXT,
|
|
160
|
+
rule_id TEXT,
|
|
161
|
+
tool_calls INTEGER NOT NULL,
|
|
162
|
+
tokens INTEGER NOT NULL,
|
|
163
|
+
latency_ms DOUBLE PRECISION NOT NULL,
|
|
164
|
+
trace JSONB,
|
|
165
|
+
agent TEXT,
|
|
166
|
+
source TEXT NOT NULL DEFAULT 'runtime',
|
|
167
|
+
actor_role TEXT
|
|
168
|
+
)
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
_CREATE_USER_RISK_SCORES = """
|
|
172
|
+
CREATE TABLE IF NOT EXISTS user_risk_scores (
|
|
173
|
+
user_hash TEXT PRIMARY KEY,
|
|
174
|
+
score DOUBLE PRECISION NOT NULL,
|
|
175
|
+
level TEXT NOT NULL,
|
|
176
|
+
factors JSONB NOT NULL DEFAULT '[]',
|
|
177
|
+
computed_at TIMESTAMPTZ NOT NULL,
|
|
178
|
+
pardoned_until TIMESTAMPTZ
|
|
179
|
+
)
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
_ALTER_ADD_PARDONED_UNTIL = """
|
|
183
|
+
ALTER TABLE user_risk_scores ADD COLUMN IF NOT EXISTS pardoned_until TIMESTAMPTZ
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
_RISK_SCORE_INDEXES = [
|
|
187
|
+
"CREATE INDEX IF NOT EXISTS idx_urs_score ON user_risk_scores (score DESC)",
|
|
188
|
+
"CREATE INDEX IF NOT EXISTS idx_urs_level ON user_risk_scores (level)",
|
|
189
|
+
"CREATE INDEX IF NOT EXISTS idx_urs_computed_at ON user_risk_scores (computed_at)",
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
_UPSERT_RISK_SCORE = """
|
|
193
|
+
INSERT INTO user_risk_scores (user_hash, score, level, factors, computed_at)
|
|
194
|
+
VALUES (%s, %s, %s, %s, %s)
|
|
195
|
+
ON CONFLICT (user_hash) DO UPDATE SET
|
|
196
|
+
score = EXCLUDED.score,
|
|
197
|
+
level = EXCLUDED.level,
|
|
198
|
+
factors = EXCLUDED.factors,
|
|
199
|
+
computed_at = EXCLUDED.computed_at
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
_SELECT_RISK_SCORE = """
|
|
203
|
+
SELECT user_hash, score, level, factors, computed_at
|
|
204
|
+
FROM user_risk_scores
|
|
205
|
+
WHERE user_hash = %s
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
_INDEXES = [
|
|
209
|
+
"CREATE INDEX IF NOT EXISTS idx_fe_timestamp ON firewall_events (timestamp)",
|
|
210
|
+
"CREATE INDEX IF NOT EXISTS idx_fe_invocation_id ON firewall_events (invocation_id)",
|
|
211
|
+
"CREATE INDEX IF NOT EXISTS idx_fe_checkpoint ON firewall_events (checkpoint)",
|
|
212
|
+
"CREATE INDEX IF NOT EXISTS idx_fe_rule_id ON firewall_events (rule_id)",
|
|
213
|
+
"CREATE INDEX IF NOT EXISTS idx_fe_user_hash ON firewall_events (user_hash)",
|
|
214
|
+
"CREATE INDEX IF NOT EXISTS idx_fe_agent ON firewall_events (agent)",
|
|
215
|
+
"CREATE INDEX IF NOT EXISTS idx_fe_source ON firewall_events (source)",
|
|
216
|
+
"CREATE INDEX IF NOT EXISTS idx_inv_timestamp ON invocations (timestamp)",
|
|
217
|
+
"CREATE INDEX IF NOT EXISTS idx_inv_user_hash ON invocations (user_hash)",
|
|
218
|
+
"CREATE INDEX IF NOT EXISTS idx_inv_blocked ON invocations (blocked)",
|
|
219
|
+
"CREATE INDEX IF NOT EXISTS idx_inv_agent ON invocations (agent)",
|
|
220
|
+
"CREATE INDEX IF NOT EXISTS idx_inv_source ON invocations (source)",
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
_INSERT_FIREWALL_EVENT = """
|
|
224
|
+
INSERT INTO firewall_events
|
|
225
|
+
(id, invocation_id, timestamp, checkpoint, rule_id, rule_name,
|
|
226
|
+
excerpt, user_hash, ip, agent, source, actor_role)
|
|
227
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
_INSERT_INVOCATION = """
|
|
231
|
+
INSERT INTO invocations
|
|
232
|
+
(id, timestamp, user_hash, question_preview, blocked, checkpoint,
|
|
233
|
+
rule_id, tool_calls, tokens, latency_ms, trace, agent, source, actor_role)
|
|
234
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ── PostgresStore ────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class PostgresStore:
|
|
242
|
+
"""Persists telemetry to PostgreSQL via psycopg2.
|
|
243
|
+
|
|
244
|
+
Lifecycle: __init__ (stores config) → connect() → use → close().
|
|
245
|
+
The constructor does NOT open a connection — call connect() after fork.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
def __init__(
|
|
249
|
+
self,
|
|
250
|
+
dsn: str,
|
|
251
|
+
trace_mode: str = "blocked",
|
|
252
|
+
sync_commit: bool = True,
|
|
253
|
+
sample_rate: int = 10,
|
|
254
|
+
) -> None:
|
|
255
|
+
self._dsn = dsn
|
|
256
|
+
self._trace_mode = trace_mode
|
|
257
|
+
self._sync_commit = sync_commit
|
|
258
|
+
self._sample_rate = sample_rate
|
|
259
|
+
self._conn: Any = None
|
|
260
|
+
self._executor: ThreadPoolExecutor | None = None
|
|
261
|
+
|
|
262
|
+
# ── Lifecycle ────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
def connect(self) -> None:
|
|
265
|
+
"""Open connection, create tables/indexes, start write executor."""
|
|
266
|
+
import psycopg2
|
|
267
|
+
|
|
268
|
+
self._conn = psycopg2.connect(self._dsn)
|
|
269
|
+
if not self._sync_commit:
|
|
270
|
+
with self._conn.cursor() as cur:
|
|
271
|
+
cur.execute("SET synchronous_commit = off")
|
|
272
|
+
self._conn.commit()
|
|
273
|
+
self._create_tables()
|
|
274
|
+
self._executor = ThreadPoolExecutor(max_workers=1)
|
|
275
|
+
|
|
276
|
+
def close(self) -> None:
|
|
277
|
+
"""Drain pending writes, then close the connection."""
|
|
278
|
+
if self._executor is not None:
|
|
279
|
+
self._executor.shutdown(wait=True)
|
|
280
|
+
self._executor = None
|
|
281
|
+
if self._conn is not None:
|
|
282
|
+
try:
|
|
283
|
+
self._conn.close()
|
|
284
|
+
except Exception:
|
|
285
|
+
pass
|
|
286
|
+
self._conn = None
|
|
287
|
+
|
|
288
|
+
def _reconnect(self) -> None:
|
|
289
|
+
"""Replace a dead connection. Called on OperationalError."""
|
|
290
|
+
import psycopg2
|
|
291
|
+
|
|
292
|
+
if self._conn is not None:
|
|
293
|
+
try:
|
|
294
|
+
self._conn.close()
|
|
295
|
+
except Exception:
|
|
296
|
+
pass
|
|
297
|
+
self._conn = psycopg2.connect(self._dsn)
|
|
298
|
+
if not self._sync_commit:
|
|
299
|
+
with self._conn.cursor() as cur:
|
|
300
|
+
cur.execute("SET synchronous_commit = off")
|
|
301
|
+
self._conn.commit()
|
|
302
|
+
|
|
303
|
+
def _create_tables(self) -> None:
|
|
304
|
+
with self._conn.cursor() as cur:
|
|
305
|
+
cur.execute(_CREATE_FIREWALL_EVENTS)
|
|
306
|
+
cur.execute(_CREATE_INVOCATIONS)
|
|
307
|
+
cur.execute(_CREATE_USER_RISK_SCORES)
|
|
308
|
+
cur.execute(_ALTER_ADD_PARDONED_UNTIL)
|
|
309
|
+
for idx in _INDEXES + _RISK_SCORE_INDEXES:
|
|
310
|
+
cur.execute(idx)
|
|
311
|
+
self._conn.commit()
|
|
312
|
+
|
|
313
|
+
# ── Trace policy ─────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
def _should_store_trace(self, blocked: bool, invocation_id: str) -> bool:
|
|
316
|
+
if self._trace_mode == "all":
|
|
317
|
+
return True
|
|
318
|
+
if self._trace_mode == "off":
|
|
319
|
+
return False
|
|
320
|
+
if self._trace_mode == "blocked":
|
|
321
|
+
return blocked
|
|
322
|
+
if self._trace_mode == "sampled":
|
|
323
|
+
if blocked:
|
|
324
|
+
return True
|
|
325
|
+
return hash(invocation_id) % self._sample_rate == 0
|
|
326
|
+
return blocked # unknown mode → fall back to "blocked"
|
|
327
|
+
|
|
328
|
+
# ── Internal insert helpers (run on executor thread) ─────────
|
|
329
|
+
|
|
330
|
+
def _do_insert_firewall_event(self, params: tuple) -> None:
|
|
331
|
+
import psycopg2
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
with self._conn.cursor() as cur:
|
|
335
|
+
cur.execute(_INSERT_FIREWALL_EVENT, params)
|
|
336
|
+
self._conn.commit()
|
|
337
|
+
except (psycopg2.OperationalError, psycopg2.InterfaceError):
|
|
338
|
+
try:
|
|
339
|
+
self._conn.rollback()
|
|
340
|
+
except Exception:
|
|
341
|
+
pass
|
|
342
|
+
self._reconnect()
|
|
343
|
+
try:
|
|
344
|
+
with self._conn.cursor() as cur:
|
|
345
|
+
cur.execute(_INSERT_FIREWALL_EVENT, params)
|
|
346
|
+
self._conn.commit()
|
|
347
|
+
except Exception:
|
|
348
|
+
logger.warning(
|
|
349
|
+
"Firewall event insert failed after reconnect", exc_info=True
|
|
350
|
+
)
|
|
351
|
+
except Exception:
|
|
352
|
+
try:
|
|
353
|
+
self._conn.rollback()
|
|
354
|
+
except Exception:
|
|
355
|
+
pass
|
|
356
|
+
logger.warning("Firewall event insert failed", exc_info=True)
|
|
357
|
+
|
|
358
|
+
def _do_insert_invocation(self, params: tuple) -> None:
|
|
359
|
+
import psycopg2
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
with self._conn.cursor() as cur:
|
|
363
|
+
cur.execute(_INSERT_INVOCATION, params)
|
|
364
|
+
self._conn.commit()
|
|
365
|
+
except (psycopg2.OperationalError, psycopg2.InterfaceError):
|
|
366
|
+
try:
|
|
367
|
+
self._conn.rollback()
|
|
368
|
+
except Exception:
|
|
369
|
+
pass
|
|
370
|
+
self._reconnect()
|
|
371
|
+
try:
|
|
372
|
+
with self._conn.cursor() as cur:
|
|
373
|
+
cur.execute(_INSERT_INVOCATION, params)
|
|
374
|
+
self._conn.commit()
|
|
375
|
+
except Exception:
|
|
376
|
+
logger.warning(
|
|
377
|
+
"Invocation insert failed after reconnect", exc_info=True
|
|
378
|
+
)
|
|
379
|
+
except Exception:
|
|
380
|
+
try:
|
|
381
|
+
self._conn.rollback()
|
|
382
|
+
except Exception:
|
|
383
|
+
pass
|
|
384
|
+
logger.warning("Invocation insert failed", exc_info=True)
|
|
385
|
+
|
|
386
|
+
# ── Done callback for submit_* futures ───────────────────────
|
|
387
|
+
|
|
388
|
+
@staticmethod
|
|
389
|
+
def _on_done(fut: Any) -> None:
|
|
390
|
+
exc = fut.exception()
|
|
391
|
+
if exc is not None:
|
|
392
|
+
logger.warning("EventStore async write failed: %s", exc)
|
|
393
|
+
|
|
394
|
+
# ── Public API: synchronous ──────────────────────────────────
|
|
395
|
+
|
|
396
|
+
def record_firewall_event(self, **kwargs: Any) -> None:
|
|
397
|
+
|
|
398
|
+
params = (
|
|
399
|
+
str(uuid.uuid4()),
|
|
400
|
+
kwargs.get("invocation_id"),
|
|
401
|
+
kwargs["timestamp"],
|
|
402
|
+
kwargs["checkpoint"],
|
|
403
|
+
kwargs["rule_id"],
|
|
404
|
+
kwargs["rule_name"],
|
|
405
|
+
kwargs.get("excerpt"),
|
|
406
|
+
kwargs.get("user_hash"),
|
|
407
|
+
kwargs.get("ip"),
|
|
408
|
+
kwargs.get("agent"),
|
|
409
|
+
kwargs.get("source", "runtime"),
|
|
410
|
+
kwargs.get("actor_role"),
|
|
411
|
+
)
|
|
412
|
+
self._do_insert_firewall_event(params)
|
|
413
|
+
|
|
414
|
+
def record_invocation(self, **kwargs: Any) -> None:
|
|
415
|
+
from psycopg2.extras import Json
|
|
416
|
+
|
|
417
|
+
blocked = kwargs["blocked"]
|
|
418
|
+
invocation_id = kwargs["invocation_id"]
|
|
419
|
+
trace = kwargs.get("trace")
|
|
420
|
+
if trace is not None and not self._should_store_trace(blocked, invocation_id):
|
|
421
|
+
trace = None
|
|
422
|
+
|
|
423
|
+
params = (
|
|
424
|
+
invocation_id,
|
|
425
|
+
kwargs["timestamp"],
|
|
426
|
+
kwargs.get("user_hash"),
|
|
427
|
+
kwargs.get("question_preview"),
|
|
428
|
+
blocked,
|
|
429
|
+
kwargs.get("checkpoint"),
|
|
430
|
+
kwargs.get("rule_id"),
|
|
431
|
+
kwargs.get("tool_calls", 0),
|
|
432
|
+
kwargs.get("tokens", 0),
|
|
433
|
+
kwargs.get("latency_ms", 0.0),
|
|
434
|
+
Json(trace) if trace is not None else None,
|
|
435
|
+
kwargs.get("agent"),
|
|
436
|
+
kwargs.get("source", "runtime"),
|
|
437
|
+
kwargs.get("actor_role"),
|
|
438
|
+
)
|
|
439
|
+
self._do_insert_invocation(params)
|
|
440
|
+
|
|
441
|
+
# ── Public API: fire-and-forget ──────────────────────────────
|
|
442
|
+
|
|
443
|
+
def submit_firewall_event(self, **kwargs: Any) -> None:
|
|
444
|
+
if self._executor is None:
|
|
445
|
+
return
|
|
446
|
+
params = (
|
|
447
|
+
str(uuid.uuid4()),
|
|
448
|
+
kwargs.get("invocation_id"),
|
|
449
|
+
kwargs["timestamp"],
|
|
450
|
+
kwargs["checkpoint"],
|
|
451
|
+
kwargs["rule_id"],
|
|
452
|
+
kwargs["rule_name"],
|
|
453
|
+
kwargs.get("excerpt"),
|
|
454
|
+
kwargs.get("user_hash"),
|
|
455
|
+
kwargs.get("ip"),
|
|
456
|
+
kwargs.get("agent"),
|
|
457
|
+
kwargs.get("source", "runtime"),
|
|
458
|
+
kwargs.get("actor_role"),
|
|
459
|
+
)
|
|
460
|
+
fut = self._executor.submit(self._do_insert_firewall_event, params)
|
|
461
|
+
fut.add_done_callback(self._on_done)
|
|
462
|
+
|
|
463
|
+
def submit_invocation(self, **kwargs: Any) -> None:
|
|
464
|
+
if self._executor is None:
|
|
465
|
+
return
|
|
466
|
+
from psycopg2.extras import Json
|
|
467
|
+
|
|
468
|
+
blocked = kwargs["blocked"]
|
|
469
|
+
invocation_id = kwargs["invocation_id"]
|
|
470
|
+
trace = kwargs.get("trace")
|
|
471
|
+
if trace is not None and not self._should_store_trace(blocked, invocation_id):
|
|
472
|
+
trace = None
|
|
473
|
+
|
|
474
|
+
params = (
|
|
475
|
+
invocation_id,
|
|
476
|
+
kwargs["timestamp"],
|
|
477
|
+
kwargs.get("user_hash"),
|
|
478
|
+
kwargs.get("question_preview"),
|
|
479
|
+
blocked,
|
|
480
|
+
kwargs.get("checkpoint"),
|
|
481
|
+
kwargs.get("rule_id"),
|
|
482
|
+
kwargs.get("tool_calls", 0),
|
|
483
|
+
kwargs.get("tokens", 0),
|
|
484
|
+
kwargs.get("latency_ms", 0.0),
|
|
485
|
+
Json(trace) if trace is not None else None,
|
|
486
|
+
kwargs.get("agent"),
|
|
487
|
+
kwargs.get("source", "runtime"),
|
|
488
|
+
kwargs.get("actor_role"),
|
|
489
|
+
)
|
|
490
|
+
fut = self._executor.submit(self._do_insert_invocation, params)
|
|
491
|
+
fut.add_done_callback(self._on_done)
|
|
492
|
+
|
|
493
|
+
# ── Risk score persistence ────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
def upsert_risk_score(self, risk_score: Any) -> None:
|
|
496
|
+
"""Persist a RiskScore to user_risk_scores (upsert on user_hash PK)."""
|
|
497
|
+
from psycopg2.extras import Json
|
|
498
|
+
|
|
499
|
+
factors_data = [
|
|
500
|
+
{
|
|
501
|
+
"name": f.name,
|
|
502
|
+
"description": f.description,
|
|
503
|
+
"contribution": f.contribution,
|
|
504
|
+
"signal_value": f.signal_value,
|
|
505
|
+
}
|
|
506
|
+
for f in risk_score.factors
|
|
507
|
+
]
|
|
508
|
+
params = (
|
|
509
|
+
risk_score.user_hash,
|
|
510
|
+
risk_score.score,
|
|
511
|
+
risk_score.level.value,
|
|
512
|
+
Json(factors_data),
|
|
513
|
+
risk_score.computed_at,
|
|
514
|
+
)
|
|
515
|
+
try:
|
|
516
|
+
with self._conn.cursor() as cur:
|
|
517
|
+
cur.execute(_UPSERT_RISK_SCORE, params)
|
|
518
|
+
self._conn.commit()
|
|
519
|
+
except Exception:
|
|
520
|
+
try:
|
|
521
|
+
self._conn.rollback()
|
|
522
|
+
except Exception:
|
|
523
|
+
pass
|
|
524
|
+
logger.warning(
|
|
525
|
+
"upsert_risk_score failed for %s", risk_score.user_hash, exc_info=True
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
def get_risk_score(self, user_hash: str) -> Any:
|
|
529
|
+
"""Fetch the stored RiskScore for user_hash, or None if not yet computed."""
|
|
530
|
+
from firewall_sdk.anomaly import RiskFactor, RiskLevel, RiskScore
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
with self._conn.cursor() as cur:
|
|
534
|
+
cur.execute(_SELECT_RISK_SCORE, (user_hash,))
|
|
535
|
+
row = cur.fetchone()
|
|
536
|
+
except Exception:
|
|
537
|
+
logger.warning("get_risk_score failed for %s", user_hash, exc_info=True)
|
|
538
|
+
return None
|
|
539
|
+
|
|
540
|
+
if row is None:
|
|
541
|
+
return None
|
|
542
|
+
|
|
543
|
+
uh, score, level_str, factors_json, computed_at = row
|
|
544
|
+
factors = [
|
|
545
|
+
RiskFactor(
|
|
546
|
+
name=f["name"],
|
|
547
|
+
description=f["description"],
|
|
548
|
+
contribution=f["contribution"],
|
|
549
|
+
signal_value=f["signal_value"],
|
|
550
|
+
)
|
|
551
|
+
for f in (factors_json or [])
|
|
552
|
+
]
|
|
553
|
+
return RiskScore(
|
|
554
|
+
user_hash=uh,
|
|
555
|
+
score=float(score),
|
|
556
|
+
level=RiskLevel(level_str),
|
|
557
|
+
factors=factors,
|
|
558
|
+
computed_at=computed_at,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
def pardon_user(self, user_hash: str, hours: int = 1) -> None:
|
|
562
|
+
"""Set a manual pardon on user_hash — overrides computed risk to LOW for `hours` hours."""
|
|
563
|
+
from datetime import timedelta, timezone
|
|
564
|
+
|
|
565
|
+
pardoned_until = datetime.now(timezone.utc) + timedelta(hours=hours)
|
|
566
|
+
try:
|
|
567
|
+
with self._conn.cursor() as cur:
|
|
568
|
+
cur.execute(
|
|
569
|
+
"""
|
|
570
|
+
INSERT INTO user_risk_scores (user_hash, score, level, factors, computed_at, pardoned_until)
|
|
571
|
+
VALUES (%s, 0.0, 'LOW', '[]', NOW(), %s)
|
|
572
|
+
ON CONFLICT (user_hash) DO UPDATE SET pardoned_until = EXCLUDED.pardoned_until
|
|
573
|
+
""",
|
|
574
|
+
(user_hash, pardoned_until),
|
|
575
|
+
)
|
|
576
|
+
self._conn.commit()
|
|
577
|
+
except Exception:
|
|
578
|
+
try:
|
|
579
|
+
self._conn.rollback()
|
|
580
|
+
except Exception:
|
|
581
|
+
pass
|
|
582
|
+
logger.warning("pardon_user failed for %s", user_hash, exc_info=True)
|
|
583
|
+
|
|
584
|
+
# ── Public API: batch inserts (synchronous, for scripts) ─────
|
|
585
|
+
|
|
586
|
+
def insert_batch_firewall_events(self, rows: list[dict]) -> None:
|
|
587
|
+
from psycopg2.extras import execute_values
|
|
588
|
+
|
|
589
|
+
if not rows:
|
|
590
|
+
return
|
|
591
|
+
values = [
|
|
592
|
+
(
|
|
593
|
+
str(uuid.uuid4()),
|
|
594
|
+
r.get("invocation_id"),
|
|
595
|
+
r["timestamp"],
|
|
596
|
+
r["checkpoint"],
|
|
597
|
+
r["rule_id"],
|
|
598
|
+
r["rule_name"],
|
|
599
|
+
r.get("excerpt"),
|
|
600
|
+
r.get("user_hash"),
|
|
601
|
+
r.get("ip"),
|
|
602
|
+
r.get("agent"),
|
|
603
|
+
r.get("source", "runtime"),
|
|
604
|
+
r.get("actor_role"),
|
|
605
|
+
)
|
|
606
|
+
for r in rows
|
|
607
|
+
]
|
|
608
|
+
with self._conn.cursor() as cur:
|
|
609
|
+
execute_values(
|
|
610
|
+
cur,
|
|
611
|
+
"""INSERT INTO firewall_events
|
|
612
|
+
(id, invocation_id, timestamp, checkpoint, rule_id, rule_name,
|
|
613
|
+
excerpt, user_hash, ip, agent, source, actor_role)
|
|
614
|
+
VALUES %s""",
|
|
615
|
+
values,
|
|
616
|
+
)
|
|
617
|
+
self._conn.commit()
|
|
618
|
+
|
|
619
|
+
def insert_batch_invocations(self, rows: list[dict]) -> None:
|
|
620
|
+
from psycopg2.extras import execute_values, Json
|
|
621
|
+
|
|
622
|
+
if not rows:
|
|
623
|
+
return
|
|
624
|
+
values = []
|
|
625
|
+
for r in rows:
|
|
626
|
+
blocked = r["blocked"]
|
|
627
|
+
invocation_id = r["invocation_id"]
|
|
628
|
+
trace = r.get("trace")
|
|
629
|
+
if trace is not None and not self._should_store_trace(
|
|
630
|
+
blocked, invocation_id
|
|
631
|
+
):
|
|
632
|
+
trace = None
|
|
633
|
+
values.append(
|
|
634
|
+
(
|
|
635
|
+
invocation_id,
|
|
636
|
+
r["timestamp"],
|
|
637
|
+
r.get("user_hash"),
|
|
638
|
+
r.get("question_preview"),
|
|
639
|
+
blocked,
|
|
640
|
+
r.get("checkpoint"),
|
|
641
|
+
r.get("rule_id"),
|
|
642
|
+
r.get("tool_calls", 0),
|
|
643
|
+
r.get("tokens", 0),
|
|
644
|
+
r.get("latency_ms", 0.0),
|
|
645
|
+
Json(trace) if trace is not None else None,
|
|
646
|
+
r.get("agent"),
|
|
647
|
+
r.get("source", "runtime"),
|
|
648
|
+
r.get("actor_role"),
|
|
649
|
+
)
|
|
650
|
+
)
|
|
651
|
+
with self._conn.cursor() as cur:
|
|
652
|
+
execute_values(
|
|
653
|
+
cur,
|
|
654
|
+
"""INSERT INTO invocations
|
|
655
|
+
(id, timestamp, user_hash, question_preview, blocked, checkpoint,
|
|
656
|
+
rule_id, tool_calls, tokens, latency_ms, trace, agent, source, actor_role)
|
|
657
|
+
VALUES %s""",
|
|
658
|
+
values,
|
|
659
|
+
)
|
|
660
|
+
self._conn.commit()
|