integrable-firewall 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- integrable_firewall/__init__.py +38 -0
- integrable_firewall/_client.py +652 -0
- integrable_firewall/_version.py +1 -0
- integrable_firewall/adk.py +304 -0
- integrable_firewall/langchain.py +305 -0
- integrable_firewall/openai_agents.py +287 -0
- integrable_firewall/py.typed +0 -0
- integrable_firewall/pydantic_ai.py +132 -0
- integrable_firewall-0.1.0.dist-info/METADATA +146 -0
- integrable_firewall-0.1.0.dist-info/RECORD +12 -0
- integrable_firewall-0.1.0.dist-info/WHEEL +4 -0
- integrable_firewall-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Connect an AI agent built in code to its firewall.
|
|
2
|
+
|
|
3
|
+
The firewall screens every user message, every tool call before it runs and
|
|
4
|
+
every tool result before the model reads it. One adapter protects one agent::
|
|
5
|
+
|
|
6
|
+
from integrable_firewall.langchain import FirewallMiddleware
|
|
7
|
+
agent = create_agent(model, tools, middleware=[FirewallMiddleware(agent="support-widget")])
|
|
8
|
+
|
|
9
|
+
Adapters: ``integrable_firewall.langchain``, ``integrable_firewall.openai_agents``,
|
|
10
|
+
``integrable_firewall.adk``, ``integrable_firewall.pydantic_ai``. Each imports
|
|
11
|
+
its framework only when it is imported itself.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from ._client import (
|
|
15
|
+
TIMEOUT_SECONDS,
|
|
16
|
+
UNREACHABLE,
|
|
17
|
+
Firewall,
|
|
18
|
+
FirewallAgentMismatch,
|
|
19
|
+
FirewallBlocked,
|
|
20
|
+
FirewallConfigError,
|
|
21
|
+
FirewallError,
|
|
22
|
+
Verdict,
|
|
23
|
+
key_variable,
|
|
24
|
+
)
|
|
25
|
+
from ._version import __version__
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Firewall",
|
|
29
|
+
"Verdict",
|
|
30
|
+
"FirewallBlocked",
|
|
31
|
+
"FirewallError",
|
|
32
|
+
"FirewallConfigError",
|
|
33
|
+
"FirewallAgentMismatch",
|
|
34
|
+
"UNREACHABLE",
|
|
35
|
+
"TIMEOUT_SECONDS",
|
|
36
|
+
"key_variable",
|
|
37
|
+
"__version__",
|
|
38
|
+
]
|
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
"""The core: settings, the three events over HTTP, the answer read, failing closed.
|
|
2
|
+
|
|
3
|
+
Wire protocol: ``packages/PROTOCOL.md`` in the firewall repository. Standard
|
|
4
|
+
library only (``urllib``), Python 3.9 or later. Every request runs in a thread
|
|
5
|
+
of its own with a total deadline of 9.5 s, so the async entry points never
|
|
6
|
+
block the event loop and a server that trickles its answer cannot hold a step
|
|
7
|
+
past the deadline.
|
|
8
|
+
|
|
9
|
+
The key is read from the ``key`` option or the environment, checked against
|
|
10
|
+
the agent name, and sent only as the ``Authorization`` header to the
|
|
11
|
+
configured URL: never logged, never put in an error message, never forwarded
|
|
12
|
+
on a redirect (redirects are not followed at all).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import dataclasses
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import math
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import threading
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.parse
|
|
27
|
+
import urllib.request
|
|
28
|
+
import uuid
|
|
29
|
+
from typing import Any, Dict, Mapping, Optional, Tuple
|
|
30
|
+
|
|
31
|
+
from ._version import __version__
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"Firewall",
|
|
35
|
+
"Verdict",
|
|
36
|
+
"FirewallBlocked",
|
|
37
|
+
"FirewallError",
|
|
38
|
+
"FirewallConfigError",
|
|
39
|
+
"FirewallAgentMismatch",
|
|
40
|
+
"UNREACHABLE",
|
|
41
|
+
"TIMEOUT_SECONDS",
|
|
42
|
+
"key_variable",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
PACKAGE = "integrable-firewall"
|
|
46
|
+
USER_AGENT = f"integrable-firewall-python/{__version__}"
|
|
47
|
+
|
|
48
|
+
#: The total time one request may take; the server's own budget is 10 s.
|
|
49
|
+
TIMEOUT_SECONDS = 9.5
|
|
50
|
+
|
|
51
|
+
#: What the model (or the app, for a user message) reads when the firewall
|
|
52
|
+
#: cannot be reached or answers with an error. Quoted from PROTOCOL.md.
|
|
53
|
+
UNREACHABLE: Dict[str, str] = {
|
|
54
|
+
"UserPromptSubmit": "Firewall: the firewall could not be reached, so this message was not processed.",
|
|
55
|
+
"PreToolUse": "Firewall: the firewall could not be reached, so this step did not run.",
|
|
56
|
+
"PostToolUse": "Firewall: the firewall could not be reached, so this result was withheld.",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# A stop the firewall answered without a reason (the server always sends one).
|
|
60
|
+
_STOPPED: Dict[str, str] = {
|
|
61
|
+
"UserPromptSubmit": "Firewall: this message was blocked.",
|
|
62
|
+
"PreToolUse": "Firewall: this step was stopped.",
|
|
63
|
+
"PostToolUse": "Firewall: this result was withheld.",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_EVENTS = ("UserPromptSubmit", "PreToolUse", "PostToolUse")
|
|
67
|
+
_AGENT_RE = re.compile(r"^[a-z0-9._-]{1,40}$")
|
|
68
|
+
_KEY_RE = re.compile(r"^fwa_[0-9a-f]{64}_(?P<agent>[a-z0-9._-]{1,40})$") # server/agent-keys.mjs AGENT_KEY_RE
|
|
69
|
+
_MAX_ANSWER_BYTES = 32 * 1024 * 1024
|
|
70
|
+
|
|
71
|
+
log = logging.getLogger("integrable_firewall")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class FirewallError(Exception):
|
|
75
|
+
"""A configuration or usage error of the firewall adapter (never a decision)."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class FirewallConfigError(FirewallError, ValueError):
|
|
79
|
+
"""The adapter's settings are wrong: the agent name, the key or the URL."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class FirewallAgentMismatch(FirewallError):
|
|
83
|
+
"""One adapter was used for a second agent: one adapter protects one agent."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class FirewallBlocked(Exception):
|
|
87
|
+
"""The firewall blocked the user's message, which did not reach the model.
|
|
88
|
+
|
|
89
|
+
``reason`` is the firewall's reason ("Firewall: ..."), for the app to answer
|
|
90
|
+
its user with or to replace with its own words. ``unreachable`` is true when
|
|
91
|
+
the block is the fail-closed answer to a firewall that could not be reached.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, reason: str, verdict: Optional["Verdict"] = None) -> None:
|
|
95
|
+
super().__init__(reason)
|
|
96
|
+
self.reason = reason
|
|
97
|
+
self.verdict = verdict
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def unreachable(self) -> bool:
|
|
101
|
+
return bool(self.verdict is not None and self.verdict.failed_closed)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclasses.dataclass(frozen=True)
|
|
105
|
+
class Verdict:
|
|
106
|
+
"""The firewall's answer, normalised.
|
|
107
|
+
|
|
108
|
+
``action`` is one of:
|
|
109
|
+
|
|
110
|
+
- ``"pass"``: go on unchanged.
|
|
111
|
+
- ``"block"``: the message does not reach the model, the tool does not run,
|
|
112
|
+
or the result is withheld; the model (or the app) reads ``reason``.
|
|
113
|
+
- ``"replace"`` (tool results only): the model reads ``value`` in place of
|
|
114
|
+
the tool's result.
|
|
115
|
+
|
|
116
|
+
``error`` is set when the verdict is the fail-closed answer to a firewall
|
|
117
|
+
that could not be reached or answered with an error; it says why (never the
|
|
118
|
+
key) and is what the adapter logs.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
action: str
|
|
122
|
+
reason: Optional[str] = None
|
|
123
|
+
value: Any = None
|
|
124
|
+
context: Optional[str] = None
|
|
125
|
+
error: Optional[str] = None
|
|
126
|
+
status: Optional[int] = None
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def passed(self) -> bool:
|
|
130
|
+
return self.action == "pass"
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def blocked(self) -> bool:
|
|
134
|
+
return self.action == "block"
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def replaced(self) -> bool:
|
|
138
|
+
return self.action == "replace"
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def failed_closed(self) -> bool:
|
|
142
|
+
return self.error is not None
|
|
143
|
+
|
|
144
|
+
def model_text(self) -> str:
|
|
145
|
+
"""The text the model reads in place of the tool's result."""
|
|
146
|
+
if self.action == "replace":
|
|
147
|
+
return value_text(self.value)
|
|
148
|
+
return self.reason or ""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def key_variable(agent: str) -> str:
|
|
152
|
+
"""The environment variable that holds one agent's key.
|
|
153
|
+
|
|
154
|
+
``support-widget`` -> ``FIREWALL_KEY_SUPPORT_WIDGET``.
|
|
155
|
+
"""
|
|
156
|
+
return "FIREWALL_KEY_" + re.sub(r"[^A-Z0-9]", "_", agent.upper())
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _env(name: str) -> Optional[str]:
|
|
160
|
+
value = os.environ.get(name)
|
|
161
|
+
if value is None:
|
|
162
|
+
return None
|
|
163
|
+
value = value.strip()
|
|
164
|
+
return value or None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
168
|
+
"""Never follow a redirect: the key goes to the configured URL only."""
|
|
169
|
+
|
|
170
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override]
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class _Timeout(Exception):
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class Firewall:
|
|
179
|
+
"""A client for one agent's firewall.
|
|
180
|
+
|
|
181
|
+
``Firewall(agent="support-widget")`` reads the URL from ``FIREWALL_URL`` and
|
|
182
|
+
the key from ``FIREWALL_KEY_SUPPORT_WIDGET``, then ``FIREWALL_KEY``; the
|
|
183
|
+
``url`` and ``key`` options come first. A key made for another agent is a
|
|
184
|
+
:class:`FirewallConfigError` here, before anything is sent. A missing URL is
|
|
185
|
+
not an error: every call fails closed and one warning names the variable.
|
|
186
|
+
|
|
187
|
+
The adapters (``integrable_firewall.langchain``, ``.openai_agents``,
|
|
188
|
+
``.adk``, ``.pydantic_ai``) build on this class; it can also be used
|
|
189
|
+
directly for a framework without an adapter.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def __init__(
|
|
193
|
+
self,
|
|
194
|
+
agent: str,
|
|
195
|
+
*,
|
|
196
|
+
url: Optional[str] = None,
|
|
197
|
+
key: Optional[str] = None,
|
|
198
|
+
framework: Optional[str] = None,
|
|
199
|
+
timeout: float = TIMEOUT_SECONDS,
|
|
200
|
+
) -> None:
|
|
201
|
+
if not isinstance(agent, str) or not _AGENT_RE.match(agent) or not agent.strip("."):
|
|
202
|
+
raise FirewallConfigError(
|
|
203
|
+
f"integrable_firewall: the agent name {agent!r} is not valid: use 1 to 40 lowercase "
|
|
204
|
+
"letters, digits, '.', '_' or '-', the name the firewall knows the agent by "
|
|
205
|
+
"(for example 'support-widget')."
|
|
206
|
+
)
|
|
207
|
+
self.agent = agent
|
|
208
|
+
self.framework = framework
|
|
209
|
+
try:
|
|
210
|
+
timeout = float(timeout)
|
|
211
|
+
except (TypeError, ValueError):
|
|
212
|
+
raise FirewallConfigError("integrable_firewall: timeout must be a number of seconds.") from None
|
|
213
|
+
if not timeout > 0:
|
|
214
|
+
raise FirewallConfigError("integrable_firewall: timeout must be more than 0 seconds.")
|
|
215
|
+
self._timeout = min(timeout, TIMEOUT_SECONDS)
|
|
216
|
+
|
|
217
|
+
resolved_url = url.strip() if isinstance(url, str) and url.strip() else _env("FIREWALL_URL")
|
|
218
|
+
self.url: Optional[str] = resolved_url.rstrip("/") if resolved_url else None
|
|
219
|
+
if self.url is not None:
|
|
220
|
+
parts = urllib.parse.urlsplit(self.url)
|
|
221
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
222
|
+
where = "the url option" if url else "FIREWALL_URL"
|
|
223
|
+
raise FirewallConfigError(
|
|
224
|
+
f"integrable_firewall: {where} must be the firewall's http or https address "
|
|
225
|
+
f"(for example https://example.cloudfront.net), not {self.url!r}."
|
|
226
|
+
)
|
|
227
|
+
if parts.query or parts.fragment:
|
|
228
|
+
raise FirewallConfigError(
|
|
229
|
+
"integrable_firewall: the firewall's address takes no query or fragment."
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
variable = key_variable(agent)
|
|
233
|
+
agent_key, shared_key = _env(variable), _env("FIREWALL_KEY")
|
|
234
|
+
resolved_key: Optional[str] = None
|
|
235
|
+
source: Optional[str] = None
|
|
236
|
+
if isinstance(key, str) and key.strip():
|
|
237
|
+
resolved_key, source = key.strip(), "the key option"
|
|
238
|
+
elif agent_key is not None:
|
|
239
|
+
resolved_key, source = agent_key, variable
|
|
240
|
+
elif shared_key is not None:
|
|
241
|
+
resolved_key, source = shared_key, "FIREWALL_KEY"
|
|
242
|
+
if resolved_key is not None:
|
|
243
|
+
match = _KEY_RE.match(resolved_key)
|
|
244
|
+
if match is None:
|
|
245
|
+
raise FirewallConfigError(
|
|
246
|
+
f"integrable_firewall: the key from {source} is not a firewall agent key "
|
|
247
|
+
"(fwa_<64 hex>_<agent name>). Use the key the firewall made for "
|
|
248
|
+
f"'{agent}', placed in {variable}."
|
|
249
|
+
)
|
|
250
|
+
if match.group("agent") != agent:
|
|
251
|
+
raise FirewallConfigError(
|
|
252
|
+
f"integrable_firewall: the key from {source} is for the agent "
|
|
253
|
+
f"'{match.group('agent')}', but this adapter protects '{agent}'. One adapter "
|
|
254
|
+
f"protects one agent: use the key made for '{agent}', placed in {variable}."
|
|
255
|
+
)
|
|
256
|
+
self._key = resolved_key
|
|
257
|
+
self._key_source = source
|
|
258
|
+
self._key_variable = variable
|
|
259
|
+
|
|
260
|
+
self._endpoint = f"{self.url}/hook/{agent}" if self.url else None
|
|
261
|
+
self._opener = urllib.request.build_opener(_NoRedirect())
|
|
262
|
+
self._pin_lock = threading.Lock()
|
|
263
|
+
self._pinned: Optional[Tuple[Any, str]] = None
|
|
264
|
+
|
|
265
|
+
if self.url is None:
|
|
266
|
+
log.warning(
|
|
267
|
+
"integrable_firewall: FIREWALL_URL is not set, so every step of the agent '%s' "
|
|
268
|
+
"fails closed until it is.",
|
|
269
|
+
agent,
|
|
270
|
+
)
|
|
271
|
+
elif self._key is not None and urllib.parse.urlsplit(self.url).scheme == "http" and not _is_loopback(self.url):
|
|
272
|
+
log.warning(
|
|
273
|
+
"integrable_firewall: the firewall's address for '%s' is plain http, so its key "
|
|
274
|
+
"travels unencrypted; use the https address.",
|
|
275
|
+
agent,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def __repr__(self) -> str:
|
|
279
|
+
return f"Firewall(agent={self.agent!r}, url={self.url!r}, key={'set' if self._key else 'none'})"
|
|
280
|
+
|
|
281
|
+
# ------------------------------------------------------------------ pinning
|
|
282
|
+
|
|
283
|
+
def pin(self, identity: Any, label: str) -> None:
|
|
284
|
+
"""Bind this adapter to the first framework agent it sees.
|
|
285
|
+
|
|
286
|
+
``identity`` is a name (compared by value) or an object (compared by
|
|
287
|
+
identity); ``label`` says what it is, for the error. Any other agent
|
|
288
|
+
raises :class:`FirewallAgentMismatch`: one adapter protects one agent.
|
|
289
|
+
"""
|
|
290
|
+
with self._pin_lock:
|
|
291
|
+
if self._pinned is None:
|
|
292
|
+
self._pinned = (identity, label)
|
|
293
|
+
return
|
|
294
|
+
pinned, pinned_label = self._pinned
|
|
295
|
+
same = pinned == identity if isinstance(pinned, str) and isinstance(identity, str) else pinned is identity
|
|
296
|
+
if same:
|
|
297
|
+
return
|
|
298
|
+
raise FirewallAgentMismatch(
|
|
299
|
+
f"integrable_firewall: one adapter protects one agent. This adapter is for "
|
|
300
|
+
f"'{self.agent}' and protects {pinned_label}, but it was just used by {label}, so the "
|
|
301
|
+
f"firewall stopped there. Give {label} an adapter of its own, with its own agent name and key."
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# ------------------------------------------------------------------ events
|
|
305
|
+
|
|
306
|
+
def user_message(self, prompt: str, *, session_id: Optional[str] = None) -> Verdict:
|
|
307
|
+
"""Screen a new message from the agent's user (``UserPromptSubmit``)."""
|
|
308
|
+
return self._send("UserPromptSubmit", self._payload("UserPromptSubmit", session_id, prompt=to_text(prompt)))
|
|
309
|
+
|
|
310
|
+
def before_tool(
|
|
311
|
+
self,
|
|
312
|
+
tool_name: str,
|
|
313
|
+
tool_input: Any,
|
|
314
|
+
*,
|
|
315
|
+
session_id: Optional[str] = None,
|
|
316
|
+
tool_use_id: Optional[str] = None,
|
|
317
|
+
) -> Verdict:
|
|
318
|
+
"""Screen a tool call before it runs (``PreToolUse``)."""
|
|
319
|
+
return self._send("PreToolUse", self._tool_payload("PreToolUse", session_id, tool_name, tool_input, tool_use_id))
|
|
320
|
+
|
|
321
|
+
def after_tool(
|
|
322
|
+
self,
|
|
323
|
+
tool_name: str,
|
|
324
|
+
tool_input: Any,
|
|
325
|
+
tool_response: Any,
|
|
326
|
+
*,
|
|
327
|
+
session_id: Optional[str] = None,
|
|
328
|
+
tool_use_id: Optional[str] = None,
|
|
329
|
+
) -> Verdict:
|
|
330
|
+
"""Screen a tool's result before the model reads it (``PostToolUse``)."""
|
|
331
|
+
payload = self._tool_payload("PostToolUse", session_id, tool_name, tool_input, tool_use_id)
|
|
332
|
+
payload["tool_response"] = jsonable(tool_response)
|
|
333
|
+
return self._send("PostToolUse", payload)
|
|
334
|
+
|
|
335
|
+
async def auser_message(self, prompt: str, *, session_id: Optional[str] = None) -> Verdict:
|
|
336
|
+
return await self._asend("UserPromptSubmit", self._payload("UserPromptSubmit", session_id, prompt=to_text(prompt)))
|
|
337
|
+
|
|
338
|
+
async def abefore_tool(
|
|
339
|
+
self,
|
|
340
|
+
tool_name: str,
|
|
341
|
+
tool_input: Any,
|
|
342
|
+
*,
|
|
343
|
+
session_id: Optional[str] = None,
|
|
344
|
+
tool_use_id: Optional[str] = None,
|
|
345
|
+
) -> Verdict:
|
|
346
|
+
return await self._asend("PreToolUse", self._tool_payload("PreToolUse", session_id, tool_name, tool_input, tool_use_id))
|
|
347
|
+
|
|
348
|
+
async def aafter_tool(
|
|
349
|
+
self,
|
|
350
|
+
tool_name: str,
|
|
351
|
+
tool_input: Any,
|
|
352
|
+
tool_response: Any,
|
|
353
|
+
*,
|
|
354
|
+
session_id: Optional[str] = None,
|
|
355
|
+
tool_use_id: Optional[str] = None,
|
|
356
|
+
) -> Verdict:
|
|
357
|
+
payload = self._tool_payload("PostToolUse", session_id, tool_name, tool_input, tool_use_id)
|
|
358
|
+
payload["tool_response"] = jsonable(tool_response)
|
|
359
|
+
return await self._asend("PostToolUse", payload)
|
|
360
|
+
|
|
361
|
+
def check_user_message(self, prompt: str, *, session_id: Optional[str] = None) -> None:
|
|
362
|
+
"""Like :meth:`user_message`, raising :class:`FirewallBlocked` on a block."""
|
|
363
|
+
verdict = self.user_message(prompt, session_id=session_id)
|
|
364
|
+
if not verdict.passed:
|
|
365
|
+
raise FirewallBlocked(verdict.reason or UNREACHABLE["UserPromptSubmit"], verdict)
|
|
366
|
+
|
|
367
|
+
async def acheck_user_message(self, prompt: str, *, session_id: Optional[str] = None) -> None:
|
|
368
|
+
verdict = await self.auser_message(prompt, session_id=session_id)
|
|
369
|
+
if not verdict.passed:
|
|
370
|
+
raise FirewallBlocked(verdict.reason or UNREACHABLE["UserPromptSubmit"], verdict)
|
|
371
|
+
|
|
372
|
+
# ------------------------------------------------------------------ internals
|
|
373
|
+
|
|
374
|
+
def _payload(self, event: str, session_id: Optional[str], **fields: Any) -> Dict[str, Any]:
|
|
375
|
+
client: Dict[str, Any] = {"package": PACKAGE, "version": __version__, "language": "python"}
|
|
376
|
+
if self.framework:
|
|
377
|
+
client["framework"] = self.framework
|
|
378
|
+
payload: Dict[str, Any] = {
|
|
379
|
+
"hook_event_name": event,
|
|
380
|
+
"session_id": str(session_id) if session_id else new_session_id(),
|
|
381
|
+
"firewall_client": client,
|
|
382
|
+
}
|
|
383
|
+
payload.update(fields)
|
|
384
|
+
return payload
|
|
385
|
+
|
|
386
|
+
def _tool_payload(
|
|
387
|
+
self, event: str, session_id: Optional[str], tool_name: str, tool_input: Any, tool_use_id: Optional[str]
|
|
388
|
+
) -> Dict[str, Any]:
|
|
389
|
+
payload = self._payload(event, session_id, tool_name=str(tool_name), tool_input=as_object(tool_input))
|
|
390
|
+
if tool_use_id:
|
|
391
|
+
payload["tool_use_id"] = str(tool_use_id)
|
|
392
|
+
return payload
|
|
393
|
+
|
|
394
|
+
def _body(self, payload: Mapping[str, Any]) -> bytes:
|
|
395
|
+
return json.dumps(payload, ensure_ascii=True, allow_nan=False, separators=(",", ":")).encode("ascii")
|
|
396
|
+
|
|
397
|
+
def _post(self, body: bytes) -> Tuple[int, bytes]:
|
|
398
|
+
assert self._endpoint is not None
|
|
399
|
+
headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT}
|
|
400
|
+
if self._key:
|
|
401
|
+
headers["Authorization"] = f"Bearer {self._key}"
|
|
402
|
+
request = urllib.request.Request(self._endpoint, data=body, headers=headers, method="POST")
|
|
403
|
+
try:
|
|
404
|
+
with self._opener.open(request, timeout=self._timeout) as response:
|
|
405
|
+
return response.status, response.read(_MAX_ANSWER_BYTES + 1)
|
|
406
|
+
except urllib.error.HTTPError as error:
|
|
407
|
+
try:
|
|
408
|
+
error.close()
|
|
409
|
+
except Exception:
|
|
410
|
+
pass
|
|
411
|
+
return error.code, b""
|
|
412
|
+
|
|
413
|
+
def _send(self, event: str, payload: Dict[str, Any]) -> Verdict:
|
|
414
|
+
if self._endpoint is None:
|
|
415
|
+
return self._fail(event, "FIREWALL_URL is not set", warn=False)
|
|
416
|
+
try:
|
|
417
|
+
body = self._body(payload)
|
|
418
|
+
except (TypeError, ValueError) as error: # jsonable() leaves nothing unserialisable
|
|
419
|
+
return self._fail(event, f"the event could not be serialised ({type(error).__name__})")
|
|
420
|
+
try:
|
|
421
|
+
status, data = _call_with_deadline(lambda: self._post(body), self._timeout)
|
|
422
|
+
except _Timeout:
|
|
423
|
+
return self._fail(event, f"no answer within {self._timeout:g} s")
|
|
424
|
+
except Exception as error:
|
|
425
|
+
return self._fail(event, _describe(error))
|
|
426
|
+
return self._read(event, status, data)
|
|
427
|
+
|
|
428
|
+
async def _asend(self, event: str, payload: Dict[str, Any]) -> Verdict:
|
|
429
|
+
if self._endpoint is None:
|
|
430
|
+
return self._fail(event, "FIREWALL_URL is not set", warn=False)
|
|
431
|
+
try:
|
|
432
|
+
body = self._body(payload)
|
|
433
|
+
except (TypeError, ValueError) as error:
|
|
434
|
+
return self._fail(event, f"the event could not be serialised ({type(error).__name__})")
|
|
435
|
+
try:
|
|
436
|
+
status, data = await _acall_with_deadline(lambda: self._post(body), self._timeout)
|
|
437
|
+
except _Timeout:
|
|
438
|
+
return self._fail(event, f"no answer within {self._timeout:g} s")
|
|
439
|
+
except Exception as error:
|
|
440
|
+
return self._fail(event, _describe(error))
|
|
441
|
+
return self._read(event, status, data)
|
|
442
|
+
|
|
443
|
+
def _fail(self, event: str, why: str, *, status: Optional[int] = None, warn: bool = True) -> Verdict:
|
|
444
|
+
if warn:
|
|
445
|
+
log.warning("integrable_firewall: %s, so the %s of the agent '%s' failed closed.", why, _NOUN[event], self.agent)
|
|
446
|
+
return Verdict("block", reason=UNREACHABLE[event], error=why, status=status)
|
|
447
|
+
|
|
448
|
+
def _read(self, event: str, status: int, data: bytes) -> Verdict:
|
|
449
|
+
if status == 204:
|
|
450
|
+
return Verdict("pass", status=204)
|
|
451
|
+
if status in (401, 403):
|
|
452
|
+
if self._key is None:
|
|
453
|
+
hint = f"no key was found: set {self._key_variable}"
|
|
454
|
+
else:
|
|
455
|
+
hint = f"the key from {self._key_source} is wrong, replaced or not for '{self.agent}'"
|
|
456
|
+
return self._fail(event, f"the firewall refused the request (HTTP {status}): {hint}", status=status)
|
|
457
|
+
if status != 200:
|
|
458
|
+
return self._fail(event, f"the firewall answered HTTP {status}", status=status)
|
|
459
|
+
if len(data) > _MAX_ANSWER_BYTES:
|
|
460
|
+
return self._fail(event, "the firewall's answer was too large to read", status=status)
|
|
461
|
+
try:
|
|
462
|
+
body = json.loads(data.decode("utf-8"))
|
|
463
|
+
except (UnicodeDecodeError, ValueError):
|
|
464
|
+
return self._fail(event, "the firewall's answer was not JSON", status=status)
|
|
465
|
+
if not isinstance(body, dict):
|
|
466
|
+
return self._fail(event, "the firewall's answer was not a JSON object", status=status)
|
|
467
|
+
return read_answer(event, body)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
_NOUN = {"UserPromptSubmit": "user message", "PreToolUse": "tool call", "PostToolUse": "tool result"}
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def read_answer(event: str, body: Mapping[str, Any]) -> Verdict:
|
|
474
|
+
"""Read a ``200`` body for one event. Anything unknown is the most restrictive outcome."""
|
|
475
|
+
if event not in _EVENTS:
|
|
476
|
+
raise ValueError(f"unknown event {event!r}")
|
|
477
|
+
hso = body.get("hookSpecificOutput")
|
|
478
|
+
hso = hso if isinstance(hso, Mapping) else {}
|
|
479
|
+
reason = _reason(body.get("reason"))
|
|
480
|
+
|
|
481
|
+
if event == "UserPromptSubmit":
|
|
482
|
+
return Verdict("block", reason=reason or _STOPPED[event], status=200)
|
|
483
|
+
|
|
484
|
+
if event == "PreToolUse":
|
|
485
|
+
# deny and ask both mean the tool does not run; anything else is read as deny.
|
|
486
|
+
return Verdict("block", reason=_reason(hso.get("permissionDecisionReason")) or reason or _STOPPED[event], status=200)
|
|
487
|
+
|
|
488
|
+
# PostToolUse
|
|
489
|
+
updated = hso.get("updatedToolOutput")
|
|
490
|
+
context = _reason(hso.get("additionalContext"))
|
|
491
|
+
if body.get("decision") == "block":
|
|
492
|
+
if updated is not None:
|
|
493
|
+
return Verdict("replace", reason=reason, value=updated, context=context, status=200)
|
|
494
|
+
return Verdict("block", reason=reason or _STOPPED[event], status=200)
|
|
495
|
+
if updated is not None:
|
|
496
|
+
return Verdict("replace", reason=reason, value=updated, context=context, status=200)
|
|
497
|
+
return Verdict("block", reason=reason or _STOPPED[event], status=200)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _reason(value: Any) -> Optional[str]:
|
|
501
|
+
if isinstance(value, str) and value.strip():
|
|
502
|
+
return value.strip()
|
|
503
|
+
return None
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _describe(error: BaseException) -> str:
|
|
507
|
+
if isinstance(error, urllib.error.URLError):
|
|
508
|
+
inner = error.reason
|
|
509
|
+
if isinstance(inner, BaseException):
|
|
510
|
+
return f"the firewall could not be reached ({type(inner).__name__}: {inner})"
|
|
511
|
+
return f"the firewall could not be reached ({inner})"
|
|
512
|
+
if isinstance(error, TimeoutError) or type(error).__name__ == "timeout":
|
|
513
|
+
return "the firewall did not answer in time"
|
|
514
|
+
return f"the firewall could not be reached ({type(error).__name__}: {error})"
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _is_loopback(url: str) -> bool:
|
|
518
|
+
host = (urllib.parse.urlsplit(url).hostname or "").lower()
|
|
519
|
+
return host in ("localhost", "::1") or host.startswith("127.") or host.endswith(".localhost")
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _call_with_deadline(fn: Any, timeout: float) -> Any:
|
|
523
|
+
"""Run ``fn`` in a daemon thread; give up after ``timeout`` seconds in all."""
|
|
524
|
+
outcome: Dict[str, Any] = {}
|
|
525
|
+
done = threading.Event()
|
|
526
|
+
|
|
527
|
+
def run() -> None:
|
|
528
|
+
try:
|
|
529
|
+
outcome["value"] = fn()
|
|
530
|
+
except BaseException as error: # handed to the caller
|
|
531
|
+
outcome["error"] = error
|
|
532
|
+
finally:
|
|
533
|
+
done.set()
|
|
534
|
+
|
|
535
|
+
threading.Thread(target=run, name="integrable-firewall-request", daemon=True).start()
|
|
536
|
+
if not done.wait(timeout):
|
|
537
|
+
raise _Timeout()
|
|
538
|
+
if "error" in outcome:
|
|
539
|
+
raise outcome["error"]
|
|
540
|
+
return outcome["value"]
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
async def _acall_with_deadline(fn: Any, timeout: float) -> Any:
|
|
544
|
+
"""The same, awaited: the event loop keeps running while the request does."""
|
|
545
|
+
loop = asyncio.get_running_loop()
|
|
546
|
+
future: "asyncio.Future[Any]" = loop.create_future()
|
|
547
|
+
|
|
548
|
+
def settle(value: Any, error: Optional[BaseException]) -> None:
|
|
549
|
+
if future.done():
|
|
550
|
+
return
|
|
551
|
+
if error is not None:
|
|
552
|
+
future.set_exception(error)
|
|
553
|
+
else:
|
|
554
|
+
future.set_result(value)
|
|
555
|
+
|
|
556
|
+
def run() -> None:
|
|
557
|
+
try:
|
|
558
|
+
value, error = fn(), None
|
|
559
|
+
except BaseException as caught:
|
|
560
|
+
value, error = None, caught
|
|
561
|
+
try:
|
|
562
|
+
loop.call_soon_threadsafe(settle, value, error)
|
|
563
|
+
except RuntimeError: # the loop closed while the request ran
|
|
564
|
+
pass
|
|
565
|
+
|
|
566
|
+
threading.Thread(target=run, name="integrable-firewall-request", daemon=True).start()
|
|
567
|
+
try:
|
|
568
|
+
return await asyncio.wait_for(future, timeout)
|
|
569
|
+
except asyncio.TimeoutError:
|
|
570
|
+
raise _Timeout() from None
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
# ---------------------------------------------------------------------- values
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def new_session_id() -> str:
|
|
577
|
+
"""A random session id, for a run the framework gives no conversation id."""
|
|
578
|
+
return uuid.uuid4().hex
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def as_object(value: Any) -> Dict[str, Any]:
|
|
582
|
+
"""A tool's arguments as a JSON object; anything else is ``{"input": value}``."""
|
|
583
|
+
if isinstance(value, Mapping):
|
|
584
|
+
return {str(k): jsonable(v) for k, v in value.items()}
|
|
585
|
+
converted = jsonable(value)
|
|
586
|
+
if isinstance(converted, dict):
|
|
587
|
+
return converted
|
|
588
|
+
return {"input": converted}
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def jsonable(value: Any, _depth: int = 0) -> Any:
|
|
592
|
+
"""``value`` as JSON: a string stays a string, an object that cannot be
|
|
593
|
+
serialised is sent as its string form."""
|
|
594
|
+
if value is None or isinstance(value, (str, bool, int)):
|
|
595
|
+
return value
|
|
596
|
+
if isinstance(value, float):
|
|
597
|
+
return value if math.isfinite(value) else str(value)
|
|
598
|
+
if _depth > 64:
|
|
599
|
+
return str(value)
|
|
600
|
+
if isinstance(value, Mapping):
|
|
601
|
+
return {str(k): jsonable(v, _depth + 1) for k, v in value.items()}
|
|
602
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
603
|
+
return [jsonable(v, _depth + 1) for v in value]
|
|
604
|
+
if isinstance(value, (bytes, bytearray)):
|
|
605
|
+
return bytes(value).decode("utf-8", errors="replace")
|
|
606
|
+
dump = getattr(value, "model_dump", None) # pydantic v2 models
|
|
607
|
+
if callable(dump) and not isinstance(value, type):
|
|
608
|
+
try:
|
|
609
|
+
return jsonable(dump(mode="json"), _depth + 1)
|
|
610
|
+
except Exception:
|
|
611
|
+
pass
|
|
612
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
613
|
+
try:
|
|
614
|
+
return jsonable(dataclasses.asdict(value), _depth + 1)
|
|
615
|
+
except Exception:
|
|
616
|
+
pass
|
|
617
|
+
return str(value)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def value_text(value: Any) -> str:
|
|
621
|
+
"""A replacement result as the text a model reads: a string as is, anything else as JSON."""
|
|
622
|
+
if isinstance(value, str):
|
|
623
|
+
return value
|
|
624
|
+
try:
|
|
625
|
+
return json.dumps(jsonable(value), ensure_ascii=False, allow_nan=False)
|
|
626
|
+
except (TypeError, ValueError):
|
|
627
|
+
return str(value)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def to_text(value: Any) -> str:
|
|
631
|
+
"""Text for a prompt or a model-visible result: parts joined with newlines."""
|
|
632
|
+
if value is None:
|
|
633
|
+
return ""
|
|
634
|
+
if isinstance(value, str):
|
|
635
|
+
return value
|
|
636
|
+
if isinstance(value, (list, tuple)):
|
|
637
|
+
parts = [_part_text(part) for part in value]
|
|
638
|
+
return "\n".join(part for part in parts if part)
|
|
639
|
+
try:
|
|
640
|
+
return json.dumps(jsonable(value), ensure_ascii=False, allow_nan=False)
|
|
641
|
+
except (TypeError, ValueError):
|
|
642
|
+
return str(value)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _part_text(part: Any) -> str:
|
|
646
|
+
if isinstance(part, str):
|
|
647
|
+
return part
|
|
648
|
+
if isinstance(part, Mapping):
|
|
649
|
+
text = part.get("text")
|
|
650
|
+
return text if isinstance(text, str) else ""
|
|
651
|
+
text = getattr(part, "text", None)
|
|
652
|
+
return text if isinstance(text, str) else ""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|