wardproof 0.3.4__tar.gz → 0.3.6__tar.gz

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.
Files changed (70) hide show
  1. {wardproof-0.3.4 → wardproof-0.3.6}/PKG-INFO +4 -4
  2. {wardproof-0.3.4 → wardproof-0.3.6}/README.md +3 -3
  3. {wardproof-0.3.4 → wardproof-0.3.6}/examples/integrations/swarms_guarded.py +3 -2
  4. wardproof-0.3.6/examples/integrations/wardproof-guard.js +122 -0
  5. {wardproof-0.3.4 → wardproof-0.3.6}/pyproject.toml +1 -1
  6. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/__init__.py +1 -1
  7. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/cli.py +26 -1
  8. wardproof-0.3.6/wardproof/server.py +221 -0
  9. wardproof-0.3.4/wardproof/server.py +0 -119
  10. {wardproof-0.3.4 → wardproof-0.3.6}/.gitignore +0 -0
  11. {wardproof-0.3.4 → wardproof-0.3.6}/CONTRIBUTING.md +0 -0
  12. {wardproof-0.3.4 → wardproof-0.3.6}/LICENSE +0 -0
  13. {wardproof-0.3.4 → wardproof-0.3.6}/SECURITY.md +0 -0
  14. {wardproof-0.3.4 → wardproof-0.3.6}/THREAT_MODEL.md +0 -0
  15. {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/README.md +0 -0
  16. {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/corpus.jsonl +0 -0
  17. {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/external/README.md +0 -0
  18. {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/external/__init__.py +0 -0
  19. {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/external/_screen.py +0 -0
  20. {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/external/agentdojo.py +0 -0
  21. {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/external/fetch_data.py +0 -0
  22. {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/external/injecagent.py +0 -0
  23. {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/heldout.py +0 -0
  24. {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/latency.py +0 -0
  25. {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/run_benchmark.py +0 -0
  26. {wardproof-0.3.4 → wardproof-0.3.6}/examples/agent_to_agent_transfer.py +0 -0
  27. {wardproof-0.3.4 → wardproof-0.3.6}/examples/integrations/README.md +0 -0
  28. {wardproof-0.3.4 → wardproof-0.3.6}/examples/integrations/agentkit_guarded.py +0 -0
  29. {wardproof-0.3.4 → wardproof-0.3.6}/examples/integrations/anthropic_tools_guarded.py +0 -0
  30. {wardproof-0.3.4 → wardproof-0.3.6}/examples/integrations/crewai_guarded.py +0 -0
  31. {wardproof-0.3.4 → wardproof-0.3.6}/examples/integrations/langgraph_guarded.py +0 -0
  32. {wardproof-0.3.4 → wardproof-0.3.6}/examples/integrations/mcp_guarded.py +0 -0
  33. {wardproof-0.3.4 → wardproof-0.3.6}/examples/integrations/openai_tools_guarded.py +0 -0
  34. {wardproof-0.3.4 → wardproof-0.3.6}/examples/integrations/skills_guard.py +0 -0
  35. {wardproof-0.3.4 → wardproof-0.3.6}/examples/integrations/venice_guarded.py +0 -0
  36. {wardproof-0.3.4 → wardproof-0.3.6}/examples/morse_injection_blocked_at_action.py +0 -0
  37. {wardproof-0.3.4 → wardproof-0.3.6}/examples/protect_defi_agent.py +0 -0
  38. {wardproof-0.3.4 → wardproof-0.3.6}/examples/protect_mcp_agent.py +0 -0
  39. {wardproof-0.3.4 → wardproof-0.3.6}/examples/protect_rag_app.py +0 -0
  40. {wardproof-0.3.4 → wardproof-0.3.6}/examples/protect_x402_payments.py +0 -0
  41. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/agents/__init__.py +0 -0
  42. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/agents/base.py +0 -0
  43. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/agents/detector.py +0 -0
  44. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/agents/responder.py +0 -0
  45. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/agents/verifier.py +0 -0
  46. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/audit/__init__.py +0 -0
  47. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/audit/ledger.py +0 -0
  48. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/audit/stix.py +0 -0
  49. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/config.py +0 -0
  50. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/guardrails/__init__.py +0 -0
  51. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/guardrails/_normalize.py +0 -0
  52. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/guardrails/base.py +0 -0
  53. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/guardrails/mcp_guard.py +0 -0
  54. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/guardrails/memory_poisoning.py +0 -0
  55. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/guardrails/prompt_injection.py +0 -0
  56. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/guardrails/tool_misuse.py +0 -0
  57. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/guardrails/transfer.py +0 -0
  58. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/guardrails/x402_payment.py +0 -0
  59. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/llm/__init__.py +0 -0
  60. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/llm/base.py +0 -0
  61. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/llm/null.py +0 -0
  62. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/llm/ollama_client.py +0 -0
  63. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/orchestration/__init__.py +0 -0
  64. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/orchestration/engine.py +0 -0
  65. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/orchestration/factory.py +0 -0
  66. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/sandbox/__init__.py +0 -0
  67. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/sandbox/executor.py +0 -0
  68. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/sandbox/permissions.py +0 -0
  69. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/schema.py +0 -0
  70. {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/standards.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wardproof
3
- Version: 0.3.4
3
+ Version: 0.3.6
4
4
  Summary: Local-first, verifiable defensive AI agent swarms that protect other AI agent systems.
5
5
  Project-URL: Homepage, https://wardproof.xyz
6
6
  Project-URL: Repository, https://github.com/Impossible-Mission-Force/wardproof
@@ -74,7 +74,7 @@ It is deliberately **small, transparent, and forkable**. The security core has
74
74
  **zero third-party dependencies** and runs **fully offline**, with a local
75
75
  model via Ollama, or with no model at all.
76
76
 
77
- > **Status: v0.3.4.** The deterministic core is built, tested, and benchmarked
77
+ > **Status: v0.3.6.** The deterministic core is built, tested, and benchmarked
78
78
  > (see [Benchmark](#benchmark)), and ships dedicated guards for x402 agent
79
79
  > payments, on-chain transfers, MCP tool calls, and skill/tool definitions, a
80
80
  > controls-to-standards map (OWASP Agentic Top 10, OWASP LLM 2025, MITRE ATLAS,
@@ -351,7 +351,7 @@ No need to touch the engine, the ledger, or the agent base classes.
351
351
  Wardproof is built to become a complete, auditable control layer for AI agents.
352
352
  The direction:
353
353
 
354
- **Now (v0.3.4)**
354
+ **Now (v0.3.6)**
355
355
  The deterministic core: schema, guardrails, Detector / Verifier / Responder, a
356
356
  capability sandbox, circuit breaker and watchdog, a hash-chained and optionally
357
357
  signed audit ledger, a reproducible adversarial benchmark, a published threat
@@ -365,7 +365,7 @@ STIX 2.1 ledger export; screening harnesses for the public AgentDojo and
365
365
  InjecAgent suites; and drop-in integration examples for OpenAI and Anthropic tool
366
366
  calling, CrewAI, LangGraph, MCP, and Coinbase AgentKit, plus Venice AI as an
367
367
  optional escalate-only second-opinion backend (alongside the existing Ollama
368
- backend).
368
+ backend). The local screening service can require a bearer token, rate-limit per client, and cap request size, all from the standard library.
369
369
 
370
370
  **Next**
371
371
  - A bundled local semantic detection layer that ships by default alongside the
@@ -24,7 +24,7 @@ It is deliberately **small, transparent, and forkable**. The security core has
24
24
  **zero third-party dependencies** and runs **fully offline**, with a local
25
25
  model via Ollama, or with no model at all.
26
26
 
27
- > **Status: v0.3.4.** The deterministic core is built, tested, and benchmarked
27
+ > **Status: v0.3.6.** The deterministic core is built, tested, and benchmarked
28
28
  > (see [Benchmark](#benchmark)), and ships dedicated guards for x402 agent
29
29
  > payments, on-chain transfers, MCP tool calls, and skill/tool definitions, a
30
30
  > controls-to-standards map (OWASP Agentic Top 10, OWASP LLM 2025, MITRE ATLAS,
@@ -301,7 +301,7 @@ No need to touch the engine, the ledger, or the agent base classes.
301
301
  Wardproof is built to become a complete, auditable control layer for AI agents.
302
302
  The direction:
303
303
 
304
- **Now (v0.3.4)**
304
+ **Now (v0.3.6)**
305
305
  The deterministic core: schema, guardrails, Detector / Verifier / Responder, a
306
306
  capability sandbox, circuit breaker and watchdog, a hash-chained and optionally
307
307
  signed audit ledger, a reproducible adversarial benchmark, a published threat
@@ -315,7 +315,7 @@ STIX 2.1 ledger export; screening harnesses for the public AgentDojo and
315
315
  InjecAgent suites; and drop-in integration examples for OpenAI and Anthropic tool
316
316
  calling, CrewAI, LangGraph, MCP, and Coinbase AgentKit, plus Venice AI as an
317
317
  optional escalate-only second-opinion backend (alongside the existing Ollama
318
- backend).
318
+ backend). The local screening service can require a bearer token, rate-limit per client, and cap request size, all from the standard library.
319
319
 
320
320
  **Next**
321
321
  - A bundled local semantic detection layer that ships by default alongside the
@@ -50,7 +50,8 @@ Run the offline demonstration:
50
50
 
51
51
  from __future__ import annotations
52
52
 
53
- from typing import Any, Callable
53
+ from collections.abc import Callable
54
+ from typing import Any
54
55
 
55
56
  from wardproof import AuditLedger, Event, Verdict, build_default_swarm
56
57
 
@@ -174,7 +175,7 @@ def _demo() -> None:
174
175
  {"function": {"name": "send_email", "arguments": {"to": "a@b.com", "subject": "hi"}}},
175
176
  ]
176
177
 
177
- for call, result in zip(calls, guarded.run_many(calls)):
178
+ for call, result in zip(calls, guarded.run_many(calls), strict=True):
178
179
  name = call["function"]["name"]
179
180
  print(f"{name:14} -> {result}")
180
181
 
@@ -0,0 +1,122 @@
1
+ // wardproof-guard.js
2
+ //
3
+ // Integration pattern suggested in a thread with Bankr (@bankrbot):
4
+ // https://x.com/bankrbot/status/2062101930273599518
5
+ //
6
+ // Thin client + Express middleware that screens an agent's tool calls and
7
+ // untrusted inputs through a running `wardproof serve` instance before they
8
+ // run. The wardproof engine is the source of truth; this file embeds no rules.
9
+ //
10
+ // Fail-closed: anything other than a clean allow (a network error, a timeout,
11
+ // a non-200 response, a malformed body, or a verdict that is not "allow")
12
+ // raises WardproofBlocked, so a failure never silently lets an action through.
13
+ //
14
+ // Wire shape (wardproof serve 0.3.6):
15
+ // POST /check with Authorization: Bearer <token> content-type application/json
16
+ // request: { kind: "tool_call"|"input", content: string, source?: string, args?: object }
17
+ // response: { verdict: string, allowed: boolean, risk: number, reasons: string[] }
18
+ // verdict is lowercase ("allow" | "block" | "quarantine" | "escalate" | "sanitize").
19
+ // The decision is taken from `allowed` (a boolean), so verdict casing never matters.
20
+
21
+ class WardproofBlocked extends Error {
22
+ constructor(verdict, reasons, data) {
23
+ super(`wardproof blocked: ${verdict} ${JSON.stringify(reasons || [])}`);
24
+ this.name = "WardproofBlocked";
25
+ this.verdict = verdict;
26
+ this.reasons = reasons || [];
27
+ this.data = data || null;
28
+ }
29
+ }
30
+
31
+ function makeGuard({ url, token, timeoutMs = 5000 }) {
32
+ if (!url) throw new Error("makeGuard: url is required (the wardproof serve address)");
33
+ const base = url.replace(/\/+$/, "");
34
+
35
+ async function check(payload) {
36
+ const controller = new AbortController();
37
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
38
+
39
+ let res;
40
+ try {
41
+ const headers = { "content-type": "application/json" };
42
+ if (token) headers["authorization"] = `Bearer ${token}`;
43
+ res = await fetch(`${base}/check`, {
44
+ method: "POST",
45
+ headers,
46
+ body: JSON.stringify(payload),
47
+ signal: controller.signal,
48
+ });
49
+ } catch (err) {
50
+ // network error or timeout: fail closed
51
+ throw new WardproofBlocked("UNREACHABLE", [`fetch: ${err.message}`], null);
52
+ } finally {
53
+ clearTimeout(timer);
54
+ }
55
+
56
+ if (!res.ok) {
57
+ const text = await res.text().catch(() => "");
58
+ throw new WardproofBlocked(`HTTP_${res.status}`, [text.slice(0, 200)], null);
59
+ }
60
+
61
+ let data;
62
+ try {
63
+ data = await res.json();
64
+ } catch (err) {
65
+ throw new WardproofBlocked("BAD_RESPONSE", ["response was not valid JSON"], null);
66
+ }
67
+
68
+ if (!data || typeof data.allowed !== "boolean") {
69
+ throw new WardproofBlocked("BAD_RESPONSE", ["missing boolean 'allowed' field"], data);
70
+ }
71
+
72
+ // the engine's decision; do not second-guess it locally
73
+ if (data.allowed !== true) {
74
+ throw new WardproofBlocked(data.verdict || "block", data.reasons || [], data);
75
+ }
76
+
77
+ return data; // { verdict, allowed: true, risk, reasons }
78
+ }
79
+
80
+ // wrap an arbitrary async tool function so its call is screened first
81
+ function wrap(toolName, fn) {
82
+ return async function screened(args) {
83
+ await check({ kind: "tool_call", content: toolName, args: args || {} });
84
+ return fn(args);
85
+ };
86
+ }
87
+
88
+ // screen a piece of untrusted text before feeding it to the model
89
+ async function screenInput(text, meta) {
90
+ return check({ kind: "input", content: String(text), args: meta || {} });
91
+ }
92
+
93
+ return { check, wrap, screenInput, WardproofBlocked };
94
+ }
95
+
96
+ // Optional Express middleware: screen the request body (or a picked field)
97
+ // before the handler runs. Returns 403 with the verdict and reasons on block.
98
+ function wardproofMiddleware({ url, token, kind = "input", pick }) {
99
+ const guard = makeGuard({ url, token });
100
+ return async function (req, res, next) {
101
+ try {
102
+ const content = pick ? pick(req) : req.body;
103
+ await guard.check({
104
+ kind,
105
+ content: typeof content === "string" ? content : JSON.stringify(content || {}),
106
+ args: { path: req.path, method: req.method },
107
+ });
108
+ next();
109
+ } catch (err) {
110
+ if (err instanceof WardproofBlocked) {
111
+ return res.status(403).json({
112
+ error: "wardproof_blocked",
113
+ verdict: err.verdict,
114
+ reasons: err.reasons,
115
+ });
116
+ }
117
+ next(err);
118
+ }
119
+ };
120
+ }
121
+
122
+ module.exports = { makeGuard, wardproofMiddleware, WardproofBlocked };
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "wardproof"
7
- version = "0.3.4"
7
+ version = "0.3.6"
8
8
  description = "Local-first, verifiable defensive AI agent swarms that protect other AI agent systems."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -16,7 +16,7 @@ from wardproof.sandbox.executor import SandboxExecutor, ToolRegistry
16
16
  from wardproof.sandbox.permissions import PermissionBroker, ToolGrant
17
17
  from wardproof.schema import Decision, Event, Finding, Severity, Verdict
18
18
 
19
- __version__ = "0.3.4"
19
+ __version__ = "0.3.6"
20
20
  __all__ = [
21
21
  "Event",
22
22
  "Decision",
@@ -104,6 +104,25 @@ def main(argv: list[str] | None = None) -> int:
104
104
  rp = sub.add_parser("serve", help="run a local HTTP service that screens one event per request")
105
105
  rp.add_argument("--host", default="127.0.0.1", help="bind address (default localhost)")
106
106
  rp.add_argument("--port", type=int, default=8787, help="bind port (default 8787)")
107
+ rp.add_argument(
108
+ "--token",
109
+ default=None,
110
+ help="require this bearer token on /check (or set WARDPROOF_TOKEN)",
111
+ )
112
+ rp.add_argument(
113
+ "--rate-limit",
114
+ type=int,
115
+ default=0,
116
+ dest="rate_limit",
117
+ help="max /check requests per client per second (0 disables)",
118
+ )
119
+ rp.add_argument(
120
+ "--max-body",
121
+ type=int,
122
+ default=256 * 1024,
123
+ dest="max_body",
124
+ help="reject a /check body larger than this many bytes",
125
+ )
107
126
  args = parser.parse_args(argv)
108
127
  if args.cmd == "verify-ledger":
109
128
  return _verify_file(args.path, args.pubkey)
@@ -114,7 +133,13 @@ def main(argv: list[str] | None = None) -> int:
114
133
  if args.cmd == "serve":
115
134
  from wardproof.server import serve
116
135
 
117
- serve(host=args.host, port=args.port)
136
+ serve(
137
+ host=args.host,
138
+ port=args.port,
139
+ token=args.token,
140
+ rate_limit=args.rate_limit,
141
+ max_body=args.max_body,
142
+ )
118
143
  return 0
119
144
  return 1
120
145
 
@@ -0,0 +1,221 @@
1
+ """A small local HTTP service that screens one event per request.
2
+
3
+ Stdlib only, no third-party dependencies. It builds the default swarm once at
4
+ startup and reuses it, so a host (an agent, a bot, any language) can gate a tool
5
+ call or input with a single HTTP request instead of spawning a process and
6
+ re-importing on every call.
7
+
8
+ The service binds to localhost by default and is meant to run next to the agent
9
+ it guards. For setups where it is reachable by more than one local caller, it
10
+ supports an optional bearer token, a simple per-client rate limit, and a request
11
+ body size cap, all from the standard library.
12
+
13
+ Endpoints:
14
+ GET /health -> {"status": "ok", "version": "..."} (never requires a token)
15
+ POST /check -> body: {"kind": "tool_call"|"input", "content": "...",
16
+ "source": "...", "args": {...}}
17
+ reply: {"verdict": "...", "allowed": true|false,
18
+ "risk": 0.0, "reasons": [...]}
19
+
20
+ Hardening (all optional, all stdlib):
21
+ - token: if set, /check requires "Authorization: Bearer <token>"; the compare
22
+ is constant-time. A token may also be supplied via the WARDPROOF_TOKEN
23
+ environment variable.
24
+ - rate_limit: max requests per client IP per second (0 disables).
25
+ - max_body: reject a /check body larger than this many bytes (fail closed).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import hmac
31
+ import json
32
+ import os
33
+ import sys
34
+ import time
35
+ import traceback
36
+ from collections import defaultdict, deque
37
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
38
+ from typing import Any
39
+
40
+ from wardproof import __version__
41
+ from wardproof.orchestration.factory import build_default_swarm
42
+ from wardproof.schema import Event, Verdict
43
+
44
+ # default cap on a /check request body; a screen payload is small
45
+ DEFAULT_MAX_BODY = 256 * 1024
46
+
47
+
48
+ def screen_event(swarm: Any, payload: dict[str, Any]) -> dict[str, Any]:
49
+ """Screen one payload with the given swarm and return a JSON-able verdict.
50
+
51
+ Shared by the HTTP handler and the tests so both exercise identical logic.
52
+ """
53
+ kind = str(payload.get("kind", "tool_call"))
54
+ content = str(payload.get("content", ""))
55
+ source = str(payload.get("source", "agent"))
56
+ metadata: dict[str, Any] = {}
57
+ args = payload.get("args")
58
+ if isinstance(args, dict):
59
+ metadata["args"] = args
60
+
61
+ out = swarm.handle(Event(kind=kind, source=source, content=content, metadata=metadata))
62
+
63
+ seen: set[str] = set()
64
+ reasons: list[str] = []
65
+ for decision in (out.detector, out.verifier):
66
+ for finding in decision.findings:
67
+ if finding.triggered and finding.reason and finding.reason not in seen:
68
+ seen.add(finding.reason)
69
+ reasons.append(finding.reason)
70
+
71
+ return {
72
+ "verdict": out.verdict.value,
73
+ "allowed": out.verdict is Verdict.ALLOW,
74
+ "risk": round(out.risk, 3),
75
+ "reasons": reasons,
76
+ }
77
+
78
+
79
+ class _RateLimiter:
80
+ """A tiny fixed-window-per-second limiter keyed by client.
81
+
82
+ Keeps the timestamps of recent requests per key and rejects once more than
83
+ ``limit`` fall inside the trailing one-second window. Not distributed and
84
+ not meant to be; it protects a single local process from a runaway caller.
85
+ """
86
+
87
+ def __init__(self, limit: int) -> None:
88
+ self.limit = limit
89
+ self._hits: dict[str, deque[float]] = defaultdict(deque)
90
+
91
+ def allow(self, key: str) -> bool:
92
+ if self.limit <= 0:
93
+ return True
94
+ now = time.monotonic()
95
+ window = self._hits[key]
96
+ cutoff = now - 1.0
97
+ while window and window[0] < cutoff:
98
+ window.popleft()
99
+ if len(window) >= self.limit:
100
+ return False
101
+ window.append(now)
102
+ return True
103
+
104
+
105
+ def _make_handler(
106
+ swarm: Any,
107
+ token: str | None,
108
+ rate_limit: int,
109
+ max_body: int,
110
+ ) -> type[BaseHTTPRequestHandler]:
111
+ limiter = _RateLimiter(rate_limit)
112
+
113
+ class Handler(BaseHTTPRequestHandler):
114
+ # one shared swarm for every request
115
+ _swarm = swarm
116
+ _token = token
117
+ _limiter = limiter
118
+ _max_body = max_body
119
+
120
+ def _send(self, code: int, body: dict[str, Any]) -> None:
121
+ raw = json.dumps(body).encode("utf-8")
122
+ self.send_response(code)
123
+ self.send_header("Content-Type", "application/json")
124
+ self.send_header("Content-Length", str(len(raw)))
125
+ self.end_headers()
126
+ self.wfile.write(raw)
127
+
128
+ def _client_key(self) -> str:
129
+ addr = self.client_address[0] if self.client_address else "unknown"
130
+ return str(addr)
131
+
132
+ def _authorized(self) -> bool:
133
+ if not self._token:
134
+ return True
135
+ header = self.headers.get("Authorization", "")
136
+ prefix = "Bearer "
137
+ if not header.startswith(prefix):
138
+ return False
139
+ presented = header[len(prefix):].strip()
140
+ # constant-time compare to avoid leaking the token by timing
141
+ return hmac.compare_digest(presented, self._token)
142
+
143
+ def do_GET(self) -> None: # noqa: N802 (stdlib name)
144
+ if self.path.rstrip("/") == "/health":
145
+ self._send(200, {"status": "ok", "version": __version__})
146
+ else:
147
+ self._send(404, {"error": "not found"})
148
+
149
+ def do_POST(self) -> None: # noqa: N802 (stdlib name)
150
+ if self.path.rstrip("/") != "/check":
151
+ self._send(404, {"error": "not found"})
152
+ return
153
+ if not self._limiter.allow(self._client_key()):
154
+ self._send(429, {"error": "rate limit exceeded"})
155
+ return
156
+ if not self._authorized():
157
+ self._send(401, {"error": "missing or invalid bearer token"})
158
+ return
159
+ try:
160
+ length = int(self.headers.get("Content-Length", 0))
161
+ except (TypeError, ValueError):
162
+ length = 0
163
+ if length > self._max_body:
164
+ self._send(413, {"error": "request body too large"})
165
+ return
166
+ raw = self.rfile.read(length) if length else b""
167
+ try:
168
+ payload = json.loads(raw or b"{}")
169
+ if not isinstance(payload, dict):
170
+ raise ValueError("body must be a JSON object")
171
+ except (json.JSONDecodeError, ValueError) as exc:
172
+ self._send(400, {"error": f"invalid JSON body: {exc}"})
173
+ return
174
+ try:
175
+ self._send(200, screen_event(self._swarm, payload))
176
+ except Exception: # fail closed: never 200 a non-screened request
177
+ # log the real cause for the operator; never leak it to the client
178
+ print("wardproof serve: screen failed", file=sys.stderr)
179
+ traceback.print_exc(file=sys.stderr)
180
+ self._send(500, {"error": "internal error"})
181
+
182
+ def log_message(self, *_args: Any) -> None:
183
+ # stay quiet by default; the host decides what to log
184
+ return
185
+
186
+ return Handler
187
+
188
+
189
+ def serve(
190
+ host: str = "127.0.0.1",
191
+ port: int = 8787,
192
+ token: str | None = None,
193
+ rate_limit: int = 0,
194
+ max_body: int = DEFAULT_MAX_BODY,
195
+ ) -> None:
196
+ """Build the swarm once and serve until interrupted.
197
+
198
+ Args:
199
+ host: bind address; defaults to localhost.
200
+ port: bind port.
201
+ token: if set (or if WARDPROOF_TOKEN is set), /check requires a matching
202
+ "Authorization: Bearer <token>" header.
203
+ rate_limit: max /check requests per client IP per second; 0 disables.
204
+ max_body: reject a /check body larger than this many bytes.
205
+ """
206
+ token = token or os.environ.get("WARDPROOF_TOKEN") or None
207
+
208
+ swarm = build_default_swarm()
209
+ handler = _make_handler(swarm, token=token, rate_limit=rate_limit, max_body=max_body)
210
+ httpd = ThreadingHTTPServer((host, port), handler)
211
+ auth_note = "token required" if token else "no token"
212
+ print(
213
+ f"wardproof serve: screening on http://{host}:{port} "
214
+ f"(POST /check, GET /health; {auth_note})"
215
+ )
216
+ try:
217
+ httpd.serve_forever()
218
+ except KeyboardInterrupt:
219
+ pass
220
+ finally:
221
+ httpd.server_close()
@@ -1,119 +0,0 @@
1
- """A small local HTTP service that screens one event per request.
2
-
3
- Stdlib only, no third-party dependencies. It builds the default swarm once at
4
- startup and reuses it, so a host (an agent, a bot, any language) can gate a tool
5
- call or input with a single HTTP request instead of spawning a process and
6
- re-importing on every call.
7
-
8
- The service binds to localhost by default and is meant to run next to the agent
9
- it guards, not to be exposed to the public internet.
10
-
11
- Endpoints:
12
- GET /health -> {"status": "ok", "version": "..."}
13
- POST /check -> body: {"kind": "tool_call"|"input", "content": "...",
14
- "source": "...", "args": {...}}
15
- reply: {"verdict": "...", "allowed": true|false,
16
- "risk": 0.0, "reasons": [...]}
17
- """
18
-
19
- from __future__ import annotations
20
-
21
- import json
22
- from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
23
- from typing import Any
24
-
25
- from wardproof import __version__
26
- from wardproof.orchestration.factory import build_default_swarm
27
- from wardproof.schema import Event, Verdict
28
-
29
-
30
- def screen_event(swarm: Any, payload: dict[str, Any]) -> dict[str, Any]:
31
- """Screen one payload with the given swarm and return a JSON-able verdict.
32
-
33
- Shared by the HTTP handler and the tests so both exercise identical logic.
34
- """
35
- kind = str(payload.get("kind", "tool_call"))
36
- content = str(payload.get("content", ""))
37
- source = str(payload.get("source", "agent"))
38
- metadata: dict[str, Any] = {}
39
- args = payload.get("args")
40
- if isinstance(args, dict):
41
- metadata["args"] = args
42
-
43
- out = swarm.handle(Event(kind=kind, source=source, content=content, metadata=metadata))
44
-
45
- seen: set[str] = set()
46
- reasons: list[str] = []
47
- for decision in (out.detector, out.verifier):
48
- for finding in decision.findings:
49
- if finding.triggered and finding.reason and finding.reason not in seen:
50
- seen.add(finding.reason)
51
- reasons.append(finding.reason)
52
-
53
- return {
54
- "verdict": out.verdict.value,
55
- "allowed": out.verdict is Verdict.ALLOW,
56
- "risk": round(out.risk, 3),
57
- "reasons": reasons,
58
- }
59
-
60
-
61
- def _make_handler(swarm: Any) -> type[BaseHTTPRequestHandler]:
62
- class Handler(BaseHTTPRequestHandler):
63
- # one shared swarm for every request
64
- _swarm = swarm
65
-
66
- def _send(self, code: int, body: dict[str, Any]) -> None:
67
- raw = json.dumps(body).encode("utf-8")
68
- self.send_response(code)
69
- self.send_header("Content-Type", "application/json")
70
- self.send_header("Content-Length", str(len(raw)))
71
- self.end_headers()
72
- self.wfile.write(raw)
73
-
74
- def do_GET(self) -> None: # noqa: N802 (stdlib name)
75
- if self.path.rstrip("/") == "/health":
76
- self._send(200, {"status": "ok", "version": __version__})
77
- else:
78
- self._send(404, {"error": "not found"})
79
-
80
- def do_POST(self) -> None: # noqa: N802 (stdlib name)
81
- if self.path.rstrip("/") != "/check":
82
- self._send(404, {"error": "not found"})
83
- return
84
- try:
85
- length = int(self.headers.get("Content-Length", 0))
86
- except (TypeError, ValueError):
87
- length = 0
88
- raw = self.rfile.read(length) if length else b""
89
- try:
90
- payload = json.loads(raw or b"{}")
91
- if not isinstance(payload, dict):
92
- raise ValueError("body must be a JSON object")
93
- except (json.JSONDecodeError, ValueError) as exc:
94
- self._send(400, {"error": f"invalid JSON body: {exc}"})
95
- return
96
- try:
97
- self._send(200, screen_event(self._swarm, payload))
98
- except Exception as exc: # fail closed: report, do not 200 a non-screen
99
- self._send(500, {"error": f"screen failed: {exc}"})
100
-
101
- def log_message(self, *_args: Any) -> None:
102
- # stay quiet by default; the host decides what to log
103
- return
104
-
105
- return Handler
106
-
107
-
108
- def serve(host: str = "127.0.0.1", port: int = 8787) -> None:
109
- """Build the swarm once and serve until interrupted."""
110
- swarm = build_default_swarm()
111
- handler = _make_handler(swarm)
112
- httpd = ThreadingHTTPServer((host, port), handler)
113
- print(f"wardproof serve: screening on http://{host}:{port} (POST /check, GET /health)")
114
- try:
115
- httpd.serve_forever()
116
- except KeyboardInterrupt:
117
- pass
118
- finally:
119
- httpd.server_close()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes