File size: 30,821 Bytes
e02228b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
"""LLM bridge: DibaGuard as a tool for any language model - MCP (stdio), OpenAI / Anthropic tool schemas, HTTP.

Tools (the same five everywhere):
  guard_status        engine state: counts, thresholds and where they come from, drift, pressure
  guard_score_flows   score a time-ordered batch of flows (FLOW_FIELDS columns) -> verdict per flow + summary
  guard_score_http    score canonical HTTP request texts (dibaguard.http.canon) -> verdict per request
  guard_calibrate     feed this site's own benign flows / requests (no attack labels) -> site thresholds
  guard_report        incident report for a time window: JSON (dibaguard.report.REPORT_SCHEMA) + text en / fa
  guard_fast_state    what is enforced now (the proxy fast path polls this)      | with --policy (shield layer):
  guard_posture       host exposure audit + remediation                        | guard_policy (show / set),
  guard_playbook      recent-attack playbook and what matched here             | guard_* act through the policy
  guard_db / guard_db_calibrate / guard_db_status   the database tier (query / audit logs)

    python -m dibaguard.llm mcp  --models models            # MCP server over stdio (Claude, Cursor, any MCP client)
    python -m dibaguard.llm http --models models --port 8787 # POST /tools/<name> JSON, GET /tools (schemas)
                                                             # GET /console: the operator console (dibaguard/console.py)
  guard_rpc           JSON-RPC calls of a chain node (method abuse, floods, mempool spam)
Over HTTP, calls that change what the layer enforces (is_control: policy set, verdicts, rule installs, port closing) need
the console token (DIBAGUARD_CONSOLE_TOKEN, or the one printed at startup) and the header X-DibaGuard-Console: 1. Reads
stay open on loopback; traffic feeds that carry sources need the feed token (DIBAGUARD_FEED_TOKEN) or the console token.
No dependency beyond the standard library; the model never sees raw tokens or private data it did not send.
"""
from __future__ import annotations

import json
import sys

import numpy as np

from dibaguard import report as rep_mod
from dibaguard.engine import FLOW_FIELDS, Guard

_FLOWS_SCHEMA = {"type": "object", "description": "columns of equal length, time-ordered",
                 "properties": {k: {"type": "array"} for k in FLOW_FIELDS}, "required": ["t", "src", "dst"]}
TOOLS = [
    {"name": "guard_status", "description": "DibaGuard engine status: traffic counts, active thresholds (site-calibrated or shipped), drift flag, attack pressure.",
     "input_schema": {"type": "object", "properties": {}}},
    {"name": "guard_score_flows", "description": "Score network flows (NetFlow-like records, time-ordered). Returns per-flow attack probability, alarm, family, 0-100 score, tier that decided, per-source risk, and a summary.",
     "input_schema": {"type": "object", "properties": {"flows": _FLOWS_SCHEMA, "max_rows": {"type": "integer", "default": 200}}, "required": ["flows"]}},
    {"name": "guard_score_http", "description": "Score HTTP requests given as canonical text ('method path | q:k=v | h:k=v | b:...'). Optional client ips, hosts, statuses, timestamps make them part of the entity stream.",
     "input_schema": {"type": "object", "properties": {"texts": {"type": "array", "items": {"type": "string"}}, "deepest": {"type": "array", "items": {"type": "integer"}},
                                                       "client": {"type": "array", "items": {"type": "string"}}, "host": {"type": "array", "items": {"type": "string"}},
                                                       "status": {"type": "array", "items": {"type": "integer"}}, "t": {"type": "array", "items": {"type": "number"}}},
                      "required": ["texts"]}},
    {"name": "guard_calibrate", "description": "Give the engine this site's own normal traffic (no attack labels): benign flows and/or benign HTTP request texts. Site thresholds and the HTTP site profile are derived from them.",
     "input_schema": {"type": "object", "properties": {"flows": _FLOWS_SCHEMA, "texts": {"type": "array", "items": {"type": "string"}}}}},
    {"name": "guard_report", "description": "Incident report for a time window (unix seconds; omit for everything logged): level, pressure, top risky entities with evidence and recommended action, recommendations, text in English and Persian.",
     "input_schema": {"type": "object", "properties": {"since": {"type": "number"}, "until": {"type": "number"}, "top": {"type": "integer", "default": 10}}}},
    {"name": "guard_fast_state", "description": "What is enforced right now (blocked / challenged / rate-limited sources with deadlines): the map the proxy fast path applies.",
     "input_schema": {"type": "object", "properties": {}}},
    {"name": "guard_posture", "description": "Audit this host's exposure (listening ports vs policy, services that must never be public, chain RPC exposure); closes what the policy allows, reports the rest.",
     "input_schema": {"type": "object", "properties": {}}},
    {"name": "guard_policy", "description": "Show the active policy (mode, actions per family, allowlist, posture rules) or change fields of it (mode, actions, allow, min_action_score, posture).",
     "input_schema": {"type": "object", "properties": {"set": {"type": "object"}}}},
    {"name": "guard_playbook", "description": "The recent-attack playbook (2024-2026): patterns, how each is seen, family and action; with counts of what was matched here.",
     "input_schema": {"type": "object", "properties": {}}},
    {"name": "guard_feedback_pending", "description": "Alarms awaiting an operator's verdict, grouped per source (newest, highest score first).",
     "input_schema": {"type": "object", "properties": {"limit": {"type": "integer", "default": 50}, "since": {"type": "number"}}}},
    {"name": "guard_feedback", "description": "The operator's verdict on a source's alarms: confirm (take the action now), reject (lift it, the source's traffic becomes benign reference), or benign_source (allowlist it). Clearing a source with hard evidence (a named signature, a dangerous database command, campaign membership) or a burst of clears returns needs_confirmation instead of applying; repeat with force=true only after the human explicitly confirms.",
     "input_schema": {"type": "object", "properties": {"entity": {"type": "string"}, "verdict": {"type": "string", "enum": ["confirm", "reject", "benign_source"]}, "note": {"type": "string"},
                                                       "force": {"type": "boolean", "description": "the human explicitly confirmed clearing hard evidence"}}, "required": ["entity", "verdict"]}},
    {"name": "guard_feedback_stats", "description": "How many alarms the operator has judged, and the alarm precision as they see it.",
     "input_schema": {"type": "object", "properties": {}}},
    {"name": "guard_rules", "description": "Show the operator's own rules, or add natural-language rules (fa/en) that the layer enforces from the first second (interpreted to a precise form, with the interpretation returned for confirmation).",
     "input_schema": {"type": "object", "properties": {"add": {"type": "array", "items": {"type": "string"}}, "interpret_only": {"type": "boolean"}}}},
    {"name": "guard_db", "description": "Score database query / audit events (t, engine, client, db, user, query, status ok|auth_fail, rows, bytes) or raw log lines of one engine (postgres, mysql, mongodb, cassandra, redis, elasticsearch). Per event: alarm, families, severity, evidence, whether the client is a known application server, the remediation to run on the database; plus the actions taken.",
     "input_schema": {"type": "object", "properties": {"events": {"type": "array", "items": {"type": "object"}}, "engine": {"type": "string"},
                                                       "lines": {"type": "array", "items": {"type": "string"}}, "max_rows": {"type": "integer", "default": 200}}}},
    {"name": "guard_db_calibrate", "description": "Give the database tier the applications' normal workload (events or raw log lines of one engine, no attack labels): which clients talk to which database, the query shapes they run, their volumes.",
     "input_schema": {"type": "object", "properties": {"events": {"type": "array", "items": {"type": "object"}}, "engine": {"type": "string"},
                                                       "lines": {"type": "array", "items": {"type": "string"}}}}},
    {"name": "guard_db_status", "description": "Database tier state: known databases with their application clients and query shapes, the learned tier's reference and threshold per database, counts, and the recent database alarms with their remediation.",
     "input_schema": {"type": "object", "properties": {"recent": {"type": "integer", "default": 20}}}},
    {"name": "guard_rpc", "description": "Score JSON-RPC calls a chain node received (bodies as JSON text or objects, one client IP per body, optional unix times / error flags): RPC method abuse, expensive-method floods, mempool spam. Returns per call alarm, family, score, playbook ids, and the actions taken.",
     "input_schema": {"type": "object", "properties": {"bodies": {"type": "array", "items": {}}, "client": {"type": "array", "items": {"type": "string"}},
                                                       "t": {"type": "array", "items": {"type": "number"}}, "node": {"type": "string", "default": "node"},
                                                       "errors": {"type": "array", "items": {"type": "boolean"}}, "max_rows": {"type": "integer", "default": 200}},
                      "required": ["bodies", "client"]}},
]
# tools that feed traffic to the engine: they change its state and the layer acts on what they carry - whoever can post
# to them can make the layer block an address of their choice. Over HTTP a feed that carries sources (client / t / flows /
# events) needs the feed token (DIBAGUARD_FEED_TOKEN, header X-DibaGuard-Feed-Token; the Go proxy sends it) or the console
# token. A score-only call (request texts without client or time) changes nothing and stays open. DIBAGUARD_FEEDS_OPEN=1
# reopens every feed (an isolated loopback deployment only); DIBAGUARD_FEEDS_NEED_TOKEN=1 demands the console token and
# the console header. Re-calibration is a control call (it rewrites what the site's normal is).
FEED_TOOLS = {"guard_score_flows", "guard_score_http", "guard_calibrate", "guard_db", "guard_db_calibrate", "guard_rpc"}
CALIBRATE_TOOLS = {"guard_calibrate", "guard_db_calibrate"}


def is_stateful_feed(name: str, args: dict) -> bool:
    """a feed whose arguments reach the entity stream or the enforcer (a score-only HTTP call does not)"""
    if name not in FEED_TOOLS:
        return False
    if name == "guard_score_http":
        return any(args.get(k) is not None for k in ("client", "t", "status"))
    return True


def is_control(name: str, args: dict, layer=None) -> bool:
    """tool calls that change what the layer enforces (verdicts, rules, policy, port closing, the calibrated normal): over
    HTTP they need the console token and the X-DibaGuard-Console header; MCP over stdio is the local process's own channel."""
    if name in CALIBRATE_TOOLS:
        return True
    if name == "guard_policy":
        return bool(args.get("set"))
    if name == "guard_feedback":
        return True
    if name == "guard_rules":
        return bool(args.get("add")) and not args.get("interpret_only")
    if name == "guard_posture":
        return layer is not None and bool(layer.policy.d["posture"].get("close_unexpected")) and layer.policy.enforcing
    return False


def _db_events(args: dict) -> list[dict]:
    ev = [dict(e) for e in (args.get("events") or [])]
    if args.get("lines"):
        from dibaguard.shield.database import PARSERS

        parse = PARSERS.get(str(args.get("engine", "")).lower())
        if parse is None:
            raise ValueError(f"to parse log lines give engine, one of {sorted(PARSERS)}")
        ev += [e for e in (parse(x) for x in args["lines"]) if e]
    for e in ev:
        if not all(k in e for k in ("t", "engine", "client")):
            raise ValueError("each database event needs t, engine and client")
        e["t"] = float(e["t"])
    return ev


def as_anthropic_tools() -> list[dict]:
    return [dict(t) for t in TOOLS]


def as_openai_tools() -> list[dict]:
    return [{"type": "function", "function": {"name": t["name"], "description": t["description"], "parameters": t["input_schema"]}} for t in TOOLS]


def _jsonable(o):
    if isinstance(o, dict):
        return {str(k): _jsonable(v) for k, v in o.items()}
    if isinstance(o, (list, tuple)):
        return [_jsonable(v) for v in o]
    if isinstance(o, (set, frozenset)):
        return [_jsonable(v) for v in sorted(o, key=str)]
    if isinstance(o, np.ndarray):
        return _jsonable(o.tolist())
    if isinstance(o, (np.floating, np.integer, np.bool_)):
        return o.item()
    return o


def _summary(out: dict, keys=("p_attack", "alarm", "family", "score", "tier", "entity_risk")) -> dict:
    al = np.asarray(out["alarm"])
    fam = np.asarray(out["family"], dtype=object)
    return {"n": int(len(al)), "alarms": int(al.sum()), "max_score": int(np.max(out["score"])) if len(al) else 0,
            "families": {f: int((fam[al] == f).sum()) for f in set(fam[al].tolist())}, "site_pressure": float(out["site_pressure"])}


class Dispatcher:
    def __init__(self, guard, layer=None):
        """guard: a Guard; layer: a shield.Layer (then verdicts are also enforced and the shield tools work)"""
        self.layer = layer
        self.guard = layer.guard if layer is not None else guard

    def call(self, name: str, args: dict) -> dict:
        g, L = self.guard, self.layer
        if name == "guard_status":
            st = g.status()
            if L is not None:
                st["enforcement"] = L.enforcer.state()
                st["position"] = {"roles": L.position["roles"], "firewall": L.position["firewall"]}
            return st
        if name == "guard_fast_state":
            return L.fast_state() if L is not None else {"block": {}, "challenge": {}, "rate_limit": {}}
        if name == "guard_posture":
            return L.posture() if L is not None else {"error": "no shield layer"}
        if name == "guard_playbook":
            from dibaguard.shield import playbook

            return {"patterns": playbook.PLAYBOOK, "matched": L.playbook_hits if L is not None else {}}
        if name == "guard_feedback_pending":
            return {"pending": L.feedback.pending(int(args.get("limit", 50)), args.get("since"))} if L is not None else {"pending": []}
        if name == "guard_feedback":
            return L.feedback.decide(args["entity"], args["verdict"], args.get("note", ""), args.get("who", "operator"),
                                     force=bool(args.get("force"))) if L is not None else {"error": "no shield layer"}
        if name == "guard_feedback_stats":
            return L.feedback.stats() if L is not None else {}
        if name == "guard_rules":
            if L is None:
                return {"error": "no shield layer"}
            if args.get("add"):
                return L.add_rules(args["add"], interpret_only=bool(args.get("interpret_only")))
            return {"rules": [r.as_dict() for r in L.rules.rules]}
        if name == "guard_policy":
            if L is None:
                return {"error": "no shield layer"}
            if args.get("set"):
                from dibaguard.shield.policy import Policy

                d = json.loads(L.policy.dump())
                for k, v in args["set"].items():
                    if isinstance(v, dict) and isinstance(d.get(k), dict):
                        d[k].update(v)
                    else:
                        d[k] = v
                L.policy = Policy(d)
                L.enforcer.policy = L.policy
            return json.loads(L.policy.dump())
        if name == "guard_score_flows":
            flows = {k: np.asarray(v) for k, v in args["flows"].items()}
            out = L.flows(flows) if L is not None else g.flows(flows)
            m = int(args.get("max_rows", 200))
            return {"summary": _summary(out), "threshold": {"value": float(out["threshold_tier1"]), "source": out["threshold_source"]},
                    "drift": bool(out["drift"]), "rows": [{k: out[k][i] for k in ("p_attack", "alarm", "family", "score", "tier", "entity_risk")}
                                                           for i in range(min(m, len(out["alarm"])))]}
        if name == "guard_score_http":
            out = (L.http if L is not None else g.http_requests)(args["texts"], args.get("deepest"), args.get("client"), args.get("host"), args.get("status"), args.get("t"))
            return {"summary": _summary(out), "threshold": {"value": float(out["threshold"]), "source": out["threshold_source"]},
                    "rows": [{k: (out[k][i] if out[k] is not None else None) for k in ("p_attack", "alarm", "family", "score", "entity_risk")}
                             for i in range(len(out["alarm"]))]}
        if name == "guard_calibrate":
            res = {}
            if args.get("flows"):
                res.update(g.calibrate_flows({k: np.asarray(v) for k, v in args["flows"].items()}))
            if args.get("texts"):
                res.update(g.calibrate_http(args["texts"]))
            res["thresholds"] = g.status()["thresholds"]
            return res
        if name == "guard_report":
            if L is not None:
                return L.report(args.get("since"), args.get("until"), int(args.get("top", 10)))
            return rep_mod.build(g, args.get("since"), args.get("until"), int(args.get("top", 10)))
        if name in ("guard_db", "guard_db_calibrate", "guard_db_status", "guard_rpc") and L is None:
            return {"error": "no shield layer"}
        if name == "guard_rpc":
            bodies = [b if isinstance(b, (str, bytes)) else json.dumps(b) for b in args["bodies"]]
            client = [str(c) for c in args["client"]]
            if len(client) != len(bodies) or (args.get("t") is not None and len(args["t"]) != len(bodies)):
                raise ValueError("bodies, client (and t) must have the same length")
            if not bodies:
                return {"summary": {"n": 0, "alarms": 0, "max_score": 0, "families": {}}, "rows": [], "actions": []}
            out = L.rpc(bodies, client, args.get("t"), str(args.get("node", "node")), args.get("errors"))
            al = np.asarray(out["alarm"], bool)
            fam = np.asarray(out["family"], dtype=object)
            return {"summary": {"n": int(len(al)), "alarms": int(al.sum()), "max_score": int(np.max(out["score"])) if len(al) else 0,
                                "families": {f: int((fam[al] == f).sum()) for f in set(fam[al].tolist())}},
                    "rows": [{"alarm": bool(al[i]), "family": fam[i], "score": int(out["score"][i]), "playbook": out["playbook"][i]}
                             for i in range(min(int(args.get("max_rows", 200)), len(al)))],
                    "actions": out["actions"]}
        if name == "guard_db":
            out = L.db(_db_events(args))
            al = [v for v in out["verdicts"] if v["alarm"]]
            fams: dict = {}
            for v in al:
                f = v["families"][0] if v["families"] else "new_client"
                fams[f] = fams.get(f, 0) + 1
            return {"summary": {"n": len(out["verdicts"]), "alarms": len(al), "max_score": max((v["score"] for v in al), default=0), "families": fams},
                    "verdicts": out["verdicts"][: int(args.get("max_rows", 200))], "actions": out["actions"]}
        if name == "guard_db_calibrate":
            return L.calibrate_db(_db_events(args))
        if name == "guard_db_status":
            d = L.dbg
            return {"calibrated": bool(d.known_clients), "dbnet": d.net is not None, "counts": dict(d.counts), "exempt_shapes": len(d.exempt_shapes),
                    "databases": {db: {"clients": len(c), "client_sample": sorted(c)[:20], "shapes": len(d.known_shapes.get(db, ())),
                                       "dbnet_reference": len(d.net_ref.get(db, ())), "dbnet_threshold": d.net_threshold(db) if d.net is not None else None}
                                  for db, c in d.known_clients.items()},
                    "policy": L.policy.d["database"],
                    "recent_alarms": [{k: v.get(k) for k in ("t", "engine", "db", "client", "user", "families", "score", "app_client", "public", "evidence", "remediation")}
                                      for v in d.log[len(d.log) - max(0, int(args.get("recent", 20))):][::-1]]}
        raise KeyError(f"unknown tool {name}")

    def call_json(self, name: str, args: dict) -> dict:
        return _jsonable(self.call(name, args))


# ---------------------------------------------------------------- MCP over stdio (JSON-RPC 2.0, protocol 2024-11-05)
def serve_mcp(disp: Dispatcher, inp=None, out=None) -> None:
    inp, out = inp or sys.stdin, out or sys.stdout

    def send(obj):
        out.write(json.dumps(obj, ensure_ascii=False) + "\n")
        out.flush()

    for line in inp:
        line = line.strip()
        if not line:
            continue
        try:
            req = json.loads(line)
        except json.JSONDecodeError:
            continue
        rid, method, params = req.get("id"), req.get("method"), req.get("params") or {}
        if method == "initialize":
            send({"jsonrpc": "2.0", "id": rid, "result": {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}},
                                                           "serverInfo": {"name": "dibaguard", "version": "2"}}})
        elif method == "tools/list":
            send({"jsonrpc": "2.0", "id": rid, "result": {"tools": [{"name": t["name"], "description": t["description"], "inputSchema": t["input_schema"]} for t in TOOLS]}})
        elif method == "tools/call":
            try:
                res = disp.call_json(params.get("name", ""), params.get("arguments") or {})
                send({"jsonrpc": "2.0", "id": rid, "result": {"content": [{"type": "text", "text": json.dumps(res, ensure_ascii=False)}], "isError": False}})
            except Exception as e:  # noqa: BLE001
                send({"jsonrpc": "2.0", "id": rid, "result": {"content": [{"type": "text", "text": f"{type(e).__name__}: {e}"}], "isError": True}})
        elif method == "ping":
            send({"jsonrpc": "2.0", "id": rid, "result": {}})
        elif rid is not None:   # notifications (no id) are ignored
            send({"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": f"method not found: {method}"}})


# ---------------------------------------------------------------- plain HTTP (stdlib)
def make_server(disp: Dispatcher, host: str = "127.0.0.1", port: int = 8787, token: str | None = None, provider="env", quiet: bool = False):
    """-> (server, console). The server is threaded (an LLM turn in the console never stalls the proxy's polling); every
    call into the engine / layer holds console.lock. token: the console secret (default: DIBAGUARD_CONSOLE_TOKEN, else
    generated and printed once to stderr). provider: "env" (DIBAGUARD_LLM_*), None (no LLM) or an object with
    complete(system, messages, tools) - tests pass a fake one."""
    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
    from urllib.parse import parse_qs

    from dibaguard import console as con

    cons = con.Console(disp, token=token, provider=provider)

    class H(BaseHTTPRequestHandler):
        server_version = "dibaguard"
        sys_version = ""

        def _send(self, code, obj, extra=None):
            body = json.dumps(con.jsonable(obj), ensure_ascii=False).encode()
            self.send_response(code)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.send_header("Cache-Control", "no-store")
            self.send_header("X-Content-Type-Options", "nosniff")
            for k, v in (extra or {}).items():
                self.send_header(k, v)
            self.end_headers()
            self.wfile.write(body)

        def _redirect(self, where):
            self.send_response(302)
            self.send_header("Location", where)
            self.send_header("Content-Length", "0")
            self.end_headers()

        def do_GET(self):  # noqa: N802
            path, _, qs = self.path.partition("?")
            if path in ("/", "/dashboard"):          # the old review page is part of the console now
                return self._redirect("/console")
            if path == "/console" or path.startswith("/console/"):
                if not cons.host_ok(self.headers.get("Host")):
                    return self._send(403, {"error": "unexpected Host header (set DIBAGUARD_CONSOLE_HOSTS to allow a name)"})
                if path == "/console":
                    body = con.PAGE_BYTES
                    self.send_response(200)
                    self.send_header("Content-Type", "text/html; charset=utf-8")
                    self.send_header("Content-Length", str(len(body)))
                    for k, v in con.PAGE_HEADERS.items():
                        self.send_header(k, v)
                    self.end_headers()
                    self.wfile.write(body)
                    return
                code, obj = cons.handle_get(path, {k: v[-1] for k, v in parse_qs(qs).items()})
                return self._send(code, obj)
            if path == "/tools":
                self._send(200, {"anthropic": as_anthropic_tools(), "openai": as_openai_tools(), "report_schema": rep_mod.REPORT_SCHEMA})
            elif path == "/health":
                self._send(200, {"ok": True})
            else:
                self._send(404, {"error": "not found"})

        def do_POST(self):  # noqa: N802
            path = self.path.partition("?")[0]
            n = int(self.headers.get("Content-Length") or 0)
            if path.startswith("/console/"):
                bad = cons.check_write(self.headers, n)
                if bad:
                    self.rfile.read(min(n, 1 << 20))
                    return self._send(bad[0], {"error": bad[1]})
                try:
                    body = json.loads(self.rfile.read(n) or b"{}")
                    if not isinstance(body, dict):
                        raise ValueError("body must be a JSON object")
                except ValueError as e:
                    return self._send(400, {"error": f"invalid JSON: {e}"})
                code, obj = cons.handle_post(path, body)
                return self._send(code, obj)
            if cons.cross_site(self.headers):            # a browser page of another site posting here: never
                self.rfile.read(n)
                return self._send(403, {"error": "cross-site request refused"})
            try:
                args = json.loads(self.rfile.read(n) or b"{}")
                if not isinstance(args, dict):
                    raise ValueError("arguments must be a JSON object")
                name = path.rsplit("/", 1)[-1]
                if is_control(name, args, disp.layer) or (cons.feeds_need_token and name in FEED_TOOLS):
                    if self.headers.get("X-DibaGuard-Console") != "1":
                        return self._send(403, {"error": f"{name} changes state: send the X-DibaGuard-Console: 1 header (CSRF guard) and the console token"})
                    if not cons.token_ok(self.headers):
                        return self._send(401, {"error": f"{name} changes state: send the console token (X-DibaGuard-Token or Authorization: Bearer)"})
                elif is_stateful_feed(name, args) and not cons.feeds_open and not (cons.feed_token_ok(self.headers) or cons.token_ok(self.headers)):
                    return self._send(401, {"error": f"{name} feeds traffic the layer acts on: send the feed token (X-DibaGuard-Feed-Token, "
                                                     f"DIBAGUARD_FEED_TOKEN) or the console token"})
                with cons.lock:
                    res = disp.call_json(name, args)
                self._send(200, res)
            except KeyError as e:
                self._send(404, {"error": str(e)})
            except Exception as e:  # noqa: BLE001
                self._send(400, {"error": f"{type(e).__name__}: {e}"})

        def log_message(self, *a):
            pass

    srv = ThreadingHTTPServer((host, port), H)
    srv.daemon_threads = True
    if not quiet:
        cons.announce(host, srv.server_address[1])
    return srv, cons


def serve_http(disp: Dispatcher, host: str = "127.0.0.1", port: int = 8787, token: str | None = None, provider="env") -> None:
    make_server(disp, host, port, token, provider)[0].serve_forever()


def main(argv=None):
    import argparse

    ap = argparse.ArgumentParser()
    ap.add_argument("mode", choices=["mcp", "http", "tools"])
    ap.add_argument("--models", default="models")
    ap.add_argument("--alpha", type=float, default=1e-3)
    ap.add_argument("--policy", default=None, help="policy.json: with it the shield layer enforces (or reports) decisions")
    ap.add_argument("--inputs", default="http,flows", help="what this instance sees: http,flows,rpc,lb")
    ap.add_argument("--audit", default=None, help="append enforcement audit records here (jsonl)")
    ap.add_argument("--backend", default="auto", help="firewall backend: auto | nftables | iptables | windows | dryrun")
    ap.add_argument("--host", default="127.0.0.1")
    ap.add_argument("--port", type=int, default=8787)
    a = ap.parse_args(argv)
    if a.mode == "tools":
        print(json.dumps({"anthropic": as_anthropic_tools(), "openai": as_openai_tools()}, ensure_ascii=False, indent=1))
        return
    if a.policy:
        from dibaguard.shield.layer import Layer

        disp = Dispatcher(None, Layer.load(a.models, a.policy, inputs={k: True for k in a.inputs.split(",") if k}, backend=a.backend, audit_path=a.audit))
    else:
        disp = Dispatcher(Guard.load(a.models, alpha=a.alpha))
    if a.mode == "mcp":
        serve_mcp(disp)
        return
    srv, cons = make_server(disp, a.host, a.port)
    hk = None
    if disp.layer is not None:                    # high availability (snapshots / replica sync) when DIBAGUARD_* is set
        from dibaguard import state
        hk = state.from_env(disp.layer, cons.lock)
    try:
        srv.serve_forever()
    finally:
        if hk is not None:
            hk.stop()                             # a final snapshot


if __name__ == "__main__":
    main()