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.
- {wardproof-0.3.4 → wardproof-0.3.6}/PKG-INFO +4 -4
- {wardproof-0.3.4 → wardproof-0.3.6}/README.md +3 -3
- {wardproof-0.3.4 → wardproof-0.3.6}/examples/integrations/swarms_guarded.py +3 -2
- wardproof-0.3.6/examples/integrations/wardproof-guard.js +122 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/pyproject.toml +1 -1
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/__init__.py +1 -1
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/cli.py +26 -1
- wardproof-0.3.6/wardproof/server.py +221 -0
- wardproof-0.3.4/wardproof/server.py +0 -119
- {wardproof-0.3.4 → wardproof-0.3.6}/.gitignore +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/CONTRIBUTING.md +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/LICENSE +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/SECURITY.md +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/THREAT_MODEL.md +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/README.md +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/corpus.jsonl +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/external/README.md +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/external/__init__.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/external/_screen.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/external/agentdojo.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/external/fetch_data.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/external/injecagent.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/heldout.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/latency.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/benchmarks/run_benchmark.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/examples/agent_to_agent_transfer.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/examples/integrations/README.md +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/examples/integrations/agentkit_guarded.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/examples/integrations/anthropic_tools_guarded.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/examples/integrations/crewai_guarded.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/examples/integrations/langgraph_guarded.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/examples/integrations/mcp_guarded.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/examples/integrations/openai_tools_guarded.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/examples/integrations/skills_guard.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/examples/integrations/venice_guarded.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/examples/morse_injection_blocked_at_action.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/examples/protect_defi_agent.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/examples/protect_mcp_agent.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/examples/protect_rag_app.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/examples/protect_x402_payments.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/agents/__init__.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/agents/base.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/agents/detector.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/agents/responder.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/agents/verifier.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/audit/__init__.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/audit/ledger.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/audit/stix.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/config.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/guardrails/__init__.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/guardrails/_normalize.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/guardrails/base.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/guardrails/mcp_guard.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/guardrails/memory_poisoning.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/guardrails/prompt_injection.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/guardrails/tool_misuse.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/guardrails/transfer.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/guardrails/x402_payment.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/llm/__init__.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/llm/base.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/llm/null.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/llm/ollama_client.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/orchestration/__init__.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/orchestration/engine.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/orchestration/factory.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/sandbox/__init__.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/sandbox/executor.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/sandbox/permissions.py +0 -0
- {wardproof-0.3.4 → wardproof-0.3.6}/wardproof/schema.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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 };
|
|
@@ -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.
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|