humanbound-firewall 0.3.2__tar.gz → 0.3.4__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.
- {humanbound_firewall-0.3.2/src/humanbound_firewall.egg-info → humanbound_firewall-0.3.4}/PKG-INFO +3 -3
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/pyproject.toml +3 -3
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/__init__.py +1 -1
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/firewall.py +14 -2
- humanbound_firewall-0.3.4/src/humanbound_firewall/firewall_judge.py +245 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/integrations/langchain.py +70 -11
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/llm/azureopenai.py +30 -23
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/llm/models.py +5 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/llm/openai.py +77 -29
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4/src/humanbound_firewall.egg-info}/PKG-INFO +3 -3
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall.egg-info/requires.txt +2 -2
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_langchain_middleware.py +171 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_llm_providers.py +102 -7
- humanbound_firewall-0.3.4/tests/test_verdict_extraction.py +292 -0
- humanbound_firewall-0.3.2/src/humanbound_firewall/firewall_judge.py +0 -140
- humanbound_firewall-0.3.2/tests/test_verdict_extraction.py +0 -96
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/LICENSE +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/NOTICE +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/README.md +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/setup.cfg +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/cache.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/config.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/guard.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/hbfw.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/integrations/__init__.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/judge.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/llm/__init__.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/llm/claude.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/llm/gemini.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/metrics.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/models.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/prompts/judge.txt +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/prompts/judge_ingest.txt +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/prompts/judge_recall.txt +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/py.typed +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/session.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall.egg-info/SOURCES.txt +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall.egg-info/dependency_links.txt +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall.egg-info/top_level.txt +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_adapt_to.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_classes.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_config.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_conformance.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_detectors.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_firewall.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_guard.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_hbfw.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_import_time.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_inspect.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_judge.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_judge_classes.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_judge_explanation.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_langchain_log_background.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_metrics.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_models.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_policy_tools.py +0 -0
- {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_session.py +0 -0
{humanbound_firewall-0.3.2/src/humanbound_firewall.egg-info → humanbound_firewall-0.3.4}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: humanbound-firewall
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Multi-tier firewall for AI agents — blocks prompt injections, jailbreaks, and scope violations; fast local tiers screen every request, only uncertain cases reach an LLM judge
|
|
5
5
|
Author-email: Humanbound <hello@humanbound.ai>
|
|
6
6
|
Maintainer-email: Demetris Gerogiannis <hello@humanbound.ai>, Kostas Siabanis <hello@humanbound.ai>
|
|
@@ -34,7 +34,7 @@ Requires-Dist: pyyaml>=6.0
|
|
|
34
34
|
Requires-Dist: pydantic>=2.0
|
|
35
35
|
Requires-Dist: requests>=2.28
|
|
36
36
|
Provides-Extra: openai
|
|
37
|
-
Requires-Dist: openai>=1.
|
|
37
|
+
Requires-Dist: openai>=1.58; extra == "openai"
|
|
38
38
|
Provides-Extra: anthropic
|
|
39
39
|
Requires-Dist: anthropic>=0.30; extra == "anthropic"
|
|
40
40
|
Provides-Extra: gemini
|
|
@@ -45,7 +45,7 @@ Requires-Dist: transformers>=4.40; extra == "tier1"
|
|
|
45
45
|
Requires-Dist: protobuf>=3.20; extra == "tier1"
|
|
46
46
|
Requires-Dist: sentencepiece>=0.1; extra == "tier1"
|
|
47
47
|
Provides-Extra: all
|
|
48
|
-
Requires-Dist: openai>=1.
|
|
48
|
+
Requires-Dist: openai>=1.58; extra == "all"
|
|
49
49
|
Requires-Dist: anthropic>=0.30; extra == "all"
|
|
50
50
|
Requires-Dist: google-generativeai>=0.3; extra == "all"
|
|
51
51
|
Requires-Dist: torch>=2.0; extra == "all"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "humanbound-firewall"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.4"
|
|
8
8
|
description = "Multi-tier firewall for AI agents — blocks prompt injections, jailbreaks, and scope violations; fast local tiers screen every request, only uncertain cases reach an LLM judge"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -49,7 +49,7 @@ dependencies = [
|
|
|
49
49
|
]
|
|
50
50
|
|
|
51
51
|
[project.optional-dependencies]
|
|
52
|
-
openai = ["openai>=1.
|
|
52
|
+
openai = ["openai>=1.58"]
|
|
53
53
|
anthropic = ["anthropic>=0.30"]
|
|
54
54
|
gemini = ["google-generativeai>=0.3"]
|
|
55
55
|
tier1 = [
|
|
@@ -59,7 +59,7 @@ tier1 = [
|
|
|
59
59
|
"sentencepiece>=0.1",
|
|
60
60
|
]
|
|
61
61
|
all = [
|
|
62
|
-
"openai>=1.
|
|
62
|
+
"openai>=1.58",
|
|
63
63
|
"anthropic>=0.30",
|
|
64
64
|
"google-generativeai>=0.3",
|
|
65
65
|
"torch>=2.0",
|
|
@@ -220,9 +220,17 @@ class Firewall:
|
|
|
220
220
|
fail: str = "open",
|
|
221
221
|
on_decision=None,
|
|
222
222
|
withheld_template: str | None = None,
|
|
223
|
+
on_refusal: str = "block",
|
|
223
224
|
):
|
|
224
225
|
"""Deployment choices (classes, mode, fail, on_decision, withheld_template) are the
|
|
225
|
-
Guard's; `mode` defaults to the policy file's settings.mode. See guard.Guard.
|
|
226
|
+
Guard's; `mode` defaults to the policy file's settings.mode. See guard.Guard.
|
|
227
|
+
|
|
228
|
+
`on_refusal` is what a Tier 3 judge that refuses to evaluate the payload means (Claude's
|
|
229
|
+
`refusal` stop, an Azure OpenAI content filter): "block" (the default: the provider's
|
|
230
|
+
safety layer judged the text harmful) or "review" (uncertain, decided by `fail`)."""
|
|
231
|
+
if on_refusal not in ("block", "review"):
|
|
232
|
+
raise ValueError(f"Unknown on_refusal '{on_refusal}'. Expected 'block' or 'review'.")
|
|
233
|
+
self._on_refusal = on_refusal
|
|
226
234
|
self._config = config
|
|
227
235
|
self._streamer = streamer
|
|
228
236
|
self._ensemble = ensemble
|
|
@@ -253,6 +261,7 @@ class Firewall:
|
|
|
253
261
|
fail: str = "open",
|
|
254
262
|
on_decision=None,
|
|
255
263
|
withheld_template: str | None = None,
|
|
264
|
+
on_refusal: str = "block",
|
|
256
265
|
) -> "Firewall":
|
|
257
266
|
"""Create a Firewall from configuration.
|
|
258
267
|
|
|
@@ -271,6 +280,8 @@ class Firewall:
|
|
|
271
280
|
on_decision: Observe-only callback fired with every Decision.
|
|
272
281
|
withheld_template: Text replacing a withheld payload; may use {category},
|
|
273
282
|
{explanation}, {cls}, {boundary}.
|
|
283
|
+
on_refusal: block | review — what a judge that refuses to evaluate the payload
|
|
284
|
+
means (a provider safety refusal or content filter).
|
|
274
285
|
"""
|
|
275
286
|
config = load_config(config_path)
|
|
276
287
|
|
|
@@ -321,6 +332,7 @@ class Firewall:
|
|
|
321
332
|
fail=fail,
|
|
322
333
|
on_decision=on_decision,
|
|
323
334
|
withheld_template=withheld_template,
|
|
335
|
+
on_refusal=on_refusal,
|
|
324
336
|
)
|
|
325
337
|
|
|
326
338
|
def evaluate(
|
|
@@ -746,7 +758,7 @@ class Firewall:
|
|
|
746
758
|
from .firewall_judge import stream_and_extract_verdict
|
|
747
759
|
|
|
748
760
|
return stream_and_extract_verdict(
|
|
749
|
-
self._streamer, system_prompt, user_prompt, timeout, session_id
|
|
761
|
+
self._streamer, system_prompt, user_prompt, timeout, session_id, self._on_refusal
|
|
750
762
|
)
|
|
751
763
|
|
|
752
764
|
def reload_config(self, config_path: str | Path):
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2024-2026 Humanbound
|
|
3
|
+
"""Streaming LLM judge — the verdict is the letter the reply starts with.
|
|
4
|
+
|
|
5
|
+
The judge answers `<letter> <explanation>`. The letter is read off the start of the reply and only
|
|
6
|
+
counts as a standalone word: the first word is read to its boundary before it is trusted, so "C"
|
|
7
|
+
followed by "ertainly" is not a restriction verdict, and a letter inside the payload's own text
|
|
8
|
+
(a judge captured into echoing it) never becomes one. A reply that opens with anything else — a
|
|
9
|
+
preamble, a continuation of the payload — is a protocol violation: the judge is asked once more,
|
|
10
|
+
tersely, and if the retry does not start with a letter either the result is "no verdict", which a
|
|
11
|
+
fail-closed deployment withholds. The first reply is dropped as soon as it is known to hold no
|
|
12
|
+
verdict, so the retry starts at once; the retry alone is read to its end.
|
|
13
|
+
|
|
14
|
+
A judge whose provider refuses to evaluate the payload — Claude stopping with `refusal`, an Azure
|
|
15
|
+
OpenAI content filter, an OpenAI `refusal` — gives no letter either, but that is not doubt: the
|
|
16
|
+
provider's own safety layer judged the text harmful. The firewall's job is to stop exactly such
|
|
17
|
+
text, so by default a refusal is a block (`on_refusal="block"`). A deployment that would rather
|
|
18
|
+
treat it as an uncertain verdict, decided by its fail mode, sets `on_refusal="review"`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import threading
|
|
22
|
+
|
|
23
|
+
from .firewall import _extract_token
|
|
24
|
+
from .models import VERDICT_MAP, Category, EvalResult, Verdict
|
|
25
|
+
|
|
26
|
+
_WRAPPERS = " \t\r\n*#`\"'_>-"
|
|
27
|
+
_RETRY_SUFFIX = "\n\nReply with the verdict letter only (P, A, B, C or D), then the explanation."
|
|
28
|
+
ON_REFUSAL = ("block", "review")
|
|
29
|
+
# What a provider's content filter says when it rejects the request itself (Azure OpenAI's
|
|
30
|
+
# prompt filter, OpenAI's moderation), as opposed to any other error.
|
|
31
|
+
_FILTER_MARKERS = ("content_filter", "content management policy", "content_policy_violation")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read_verdict(head: str, ended: bool = False):
|
|
35
|
+
"""Read the verdict off the start of the reply.
|
|
36
|
+
|
|
37
|
+
Returns (letter, remainder) once the reply is known to start with a standalone verdict
|
|
38
|
+
letter, (None, None) while more text is needed to know, and (None, "") once it is clear
|
|
39
|
+
the reply does not start with one.
|
|
40
|
+
"""
|
|
41
|
+
i = 0
|
|
42
|
+
while i < len(head) and head[i] in _WRAPPERS:
|
|
43
|
+
i += 1
|
|
44
|
+
if i >= len(head):
|
|
45
|
+
return (None, "") if ended else (None, None) # only wrappers so far
|
|
46
|
+
first = head[i]
|
|
47
|
+
if not first.isalpha() or first.upper() not in VERDICT_MAP:
|
|
48
|
+
return None, "" # the reply starts with something else
|
|
49
|
+
if i + 1 >= len(head):
|
|
50
|
+
# The word is not finished: wait for its boundary (a one-letter reply resolves at the end).
|
|
51
|
+
return (first.upper(), "") if ended else (None, None)
|
|
52
|
+
if head[i + 1].isalpha():
|
|
53
|
+
return None, "" # the letter is the start of a word
|
|
54
|
+
rest = head[i + 1 :].lstrip(_WRAPPERS.replace("-", "").replace(">", ""))
|
|
55
|
+
return first.upper(), rest
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _refusal(chunk) -> str | None:
|
|
59
|
+
"""The reason, when a streamed chunk says the provider refused to answer; else None.
|
|
60
|
+
|
|
61
|
+
Anthropic ends a refused message with `stop_reason: "refusal"` (and a `stop_details` category);
|
|
62
|
+
OpenAI and Azure OpenAI send a `refusal` text or finish with `content_filter`. Only real strings
|
|
63
|
+
count, so a chunk without these fields is never mistaken for a refusal.
|
|
64
|
+
"""
|
|
65
|
+
if getattr(chunk, "type", None) == "message_delta":
|
|
66
|
+
delta = getattr(chunk, "delta", None)
|
|
67
|
+
if getattr(delta, "stop_reason", None) == "refusal":
|
|
68
|
+
details = getattr(delta, "stop_details", None) or getattr(chunk, "stop_details", None)
|
|
69
|
+
category = getattr(details, "category", None)
|
|
70
|
+
return (
|
|
71
|
+
f"provider refusal: {category}" if isinstance(category, str) else "provider refusal"
|
|
72
|
+
)
|
|
73
|
+
return None
|
|
74
|
+
choices = getattr(chunk, "choices", None)
|
|
75
|
+
if isinstance(choices, (list, tuple)) and choices:
|
|
76
|
+
choice = choices[0]
|
|
77
|
+
refusal = getattr(getattr(choice, "delta", None), "refusal", None)
|
|
78
|
+
if isinstance(refusal, str) and refusal.strip():
|
|
79
|
+
return f"provider refusal: {refusal.strip()[:120]}"
|
|
80
|
+
if getattr(choice, "finish_reason", None) == "content_filter":
|
|
81
|
+
return "provider content filter"
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _filtered(error: BaseException) -> str | None:
|
|
86
|
+
"""The reason, when an error is the provider's content filter rejecting the request; else None."""
|
|
87
|
+
code = getattr(error, "code", None)
|
|
88
|
+
text = str(error)
|
|
89
|
+
if code == "content_filter" or any(marker in text for marker in _FILTER_MARKERS):
|
|
90
|
+
return "provider content filter"
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def stream_and_extract_verdict(
|
|
95
|
+
streamer,
|
|
96
|
+
system_prompt: str,
|
|
97
|
+
user_prompt: str,
|
|
98
|
+
timeout: int,
|
|
99
|
+
session_id: str,
|
|
100
|
+
on_refusal: str = "block",
|
|
101
|
+
) -> EvalResult:
|
|
102
|
+
"""Stream the judge's reply; return as soon as the verdict letter is known."""
|
|
103
|
+
if on_refusal not in ON_REFUSAL:
|
|
104
|
+
raise ValueError(f"Unknown on_refusal '{on_refusal}'. Expected one of {ON_REFUSAL}.")
|
|
105
|
+
result_holder: list[EvalResult | None] = [None]
|
|
106
|
+
decision_event = threading.Event()
|
|
107
|
+
explanation_ready = threading.Event()
|
|
108
|
+
|
|
109
|
+
def stream_once(user_p: str, read_to_end: bool = False):
|
|
110
|
+
"""One judge call. Returns (letter, explanation_parts, head, refusal): letter None = no
|
|
111
|
+
verdict; refusal names why the provider refused, when it did. A first reply that holds
|
|
112
|
+
no verdict is dropped where that is known, so the retry is not made to wait out a
|
|
113
|
+
preamble; only the retry is read to its end (`read_to_end`), to catch a late refusal."""
|
|
114
|
+
head, parts, letter, no_verdict = "", [], None, False
|
|
115
|
+
stream = streamer.ping(
|
|
116
|
+
system_p=system_prompt, user_p=user_p, max_tokens=1024, temperature=0.0
|
|
117
|
+
)
|
|
118
|
+
for chunk in stream:
|
|
119
|
+
if letter is None and (no_verdict or not head.strip()):
|
|
120
|
+
# A refusal counts while the reply holds no verdict: before the judge has said
|
|
121
|
+
# anything, and after what it has said is known not to be a verdict letter — a
|
|
122
|
+
# preamble answers nothing, so the refusal is the only signal there is. It does
|
|
123
|
+
# not count while a letter is still resolving ("P" before its boundary) or once
|
|
124
|
+
# one has been read: a filter that cuts an explanation short leaves the verdict.
|
|
125
|
+
refused = _refusal(chunk)
|
|
126
|
+
if refused:
|
|
127
|
+
return None, [], head, refused
|
|
128
|
+
try:
|
|
129
|
+
token = _extract_token(chunk)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
# A chunk that cannot be read is not an answer. Gemini raises here when its
|
|
132
|
+
# safety filter blocked the reply, so the refusal is the chunk itself.
|
|
133
|
+
if letter is not None:
|
|
134
|
+
break # the verdict is already in; stop reading the explanation
|
|
135
|
+
return None, [], head, f"provider refusal: unreadable chunk ({type(e).__name__})"
|
|
136
|
+
if no_verdict or token is None:
|
|
137
|
+
# The reply does not start with a letter. Nothing more is read from it, but the
|
|
138
|
+
# stream is followed to its end: a refusal still decides, and arrives last.
|
|
139
|
+
continue
|
|
140
|
+
if letter is not None:
|
|
141
|
+
parts.append(token)
|
|
142
|
+
continue
|
|
143
|
+
head += token
|
|
144
|
+
found, remainder = _read_verdict(head)
|
|
145
|
+
if found is None:
|
|
146
|
+
if remainder is not None:
|
|
147
|
+
no_verdict = True # decided: no verdict, but a refusal may still follow
|
|
148
|
+
if not read_to_end:
|
|
149
|
+
# Leave at once: the retry must not wait out a long preamble, or a
|
|
150
|
+
# judge that only thinks out loud costs the caller its whole timeout.
|
|
151
|
+
return None, [], head, None
|
|
152
|
+
continue
|
|
153
|
+
letter = found
|
|
154
|
+
_decide(letter)
|
|
155
|
+
if remainder.strip():
|
|
156
|
+
parts.append(remainder)
|
|
157
|
+
if letter is None and not no_verdict:
|
|
158
|
+
found, remainder = _read_verdict(head, ended=True)
|
|
159
|
+
if found is not None:
|
|
160
|
+
letter = found
|
|
161
|
+
_decide(letter)
|
|
162
|
+
return letter, parts, head, None
|
|
163
|
+
|
|
164
|
+
def _decide(letter):
|
|
165
|
+
verdict, category = VERDICT_MAP[letter]
|
|
166
|
+
result_holder[0] = EvalResult(
|
|
167
|
+
verdict=verdict,
|
|
168
|
+
category=category,
|
|
169
|
+
raw_letter=letter,
|
|
170
|
+
session_id=session_id,
|
|
171
|
+
_explanation_ready=explanation_ready,
|
|
172
|
+
)
|
|
173
|
+
decision_event.set()
|
|
174
|
+
|
|
175
|
+
def _refused(reason: str):
|
|
176
|
+
"""The provider refused to evaluate the payload: a block by default."""
|
|
177
|
+
if on_refusal == "block":
|
|
178
|
+
result_holder[0] = EvalResult(
|
|
179
|
+
verdict=Verdict.BLOCK,
|
|
180
|
+
category=Category.VIOLATION,
|
|
181
|
+
explanation=f"Judge refused to evaluate the payload ({reason}); treated as an attack.",
|
|
182
|
+
session_id=session_id,
|
|
183
|
+
)
|
|
184
|
+
else:
|
|
185
|
+
result_holder[0] = EvalResult(
|
|
186
|
+
verdict=Verdict.REVIEW,
|
|
187
|
+
category=Category.UNCERTAIN,
|
|
188
|
+
explanation=f"Judge refused to evaluate the payload ({reason}).",
|
|
189
|
+
session_id=session_id,
|
|
190
|
+
)
|
|
191
|
+
decision_event.set()
|
|
192
|
+
|
|
193
|
+
def _run():
|
|
194
|
+
try:
|
|
195
|
+
letter, parts, head, refused = stream_once(user_prompt)
|
|
196
|
+
if letter is None and not refused and head.strip():
|
|
197
|
+
# A protocol violation: ask once more, tersely.
|
|
198
|
+
letter, parts, head2, refused = stream_once(
|
|
199
|
+
user_prompt + _RETRY_SUFFIX, read_to_end=True
|
|
200
|
+
)
|
|
201
|
+
head = head2 if letter is None else head
|
|
202
|
+
except Exception as e:
|
|
203
|
+
if result_holder[0] is None:
|
|
204
|
+
filtered = _filtered(e)
|
|
205
|
+
if filtered:
|
|
206
|
+
_refused(filtered)
|
|
207
|
+
else:
|
|
208
|
+
result_holder[0] = EvalResult(
|
|
209
|
+
verdict=Verdict.REVIEW,
|
|
210
|
+
category=Category.UNCERTAIN,
|
|
211
|
+
explanation=f"Stream error: {str(e)[:200]}",
|
|
212
|
+
session_id=session_id,
|
|
213
|
+
)
|
|
214
|
+
decision_event.set()
|
|
215
|
+
explanation_ready.set()
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
if result_holder[0] is not None:
|
|
219
|
+
explanation = "".join(parts).strip()
|
|
220
|
+
result_holder[0].explanation = explanation.lstrip(" \t\r\n*#`\"'_").strip()
|
|
221
|
+
elif refused:
|
|
222
|
+
_refused(refused)
|
|
223
|
+
else:
|
|
224
|
+
said = head.strip().replace("\n", " ")[:120]
|
|
225
|
+
explanation = (
|
|
226
|
+
f"Judge did not start with a verdict: '{said}'"
|
|
227
|
+
if said
|
|
228
|
+
else "Judge returned no verdict."
|
|
229
|
+
)
|
|
230
|
+
result_holder[0] = EvalResult(
|
|
231
|
+
verdict=Verdict.REVIEW,
|
|
232
|
+
category=Category.UNCERTAIN,
|
|
233
|
+
explanation=explanation,
|
|
234
|
+
session_id=session_id,
|
|
235
|
+
)
|
|
236
|
+
decision_event.set()
|
|
237
|
+
explanation_ready.set()
|
|
238
|
+
|
|
239
|
+
thread = threading.Thread(target=_run, daemon=True)
|
|
240
|
+
thread.start()
|
|
241
|
+
|
|
242
|
+
if not decision_event.wait(timeout=timeout):
|
|
243
|
+
raise TimeoutError("Evaluation timed out waiting for verdict.")
|
|
244
|
+
|
|
245
|
+
return result_holder[0]
|
|
@@ -34,6 +34,7 @@ Usage:
|
|
|
34
34
|
from __future__ import annotations
|
|
35
35
|
|
|
36
36
|
import asyncio
|
|
37
|
+
import json
|
|
37
38
|
import logging
|
|
38
39
|
import threading
|
|
39
40
|
from collections import deque
|
|
@@ -215,8 +216,8 @@ class HumanboundFirewallMiddleware(AgentMiddleware):
|
|
|
215
216
|
return written # a later model call in the same run: the turn was judged already
|
|
216
217
|
if self.firewall.guard.mode_for("request") == "off":
|
|
217
218
|
return written
|
|
218
|
-
text = messages[-1].content
|
|
219
|
-
if
|
|
219
|
+
text = _text(messages[-1].content)
|
|
220
|
+
if text is None: # no text to judge (an image alone)
|
|
220
221
|
return written
|
|
221
222
|
window = self._window(messages[:-1])
|
|
222
223
|
if self._background("request"): # log mode: a request is never rejected, so do not wait
|
|
@@ -277,13 +278,15 @@ class HumanboundFirewallMiddleware(AgentMiddleware):
|
|
|
277
278
|
return not self._log_blocking and self.firewall.guard.mode_for(cls) == "log"
|
|
278
279
|
|
|
279
280
|
def _screen(self, request, result):
|
|
280
|
-
if not isinstance(result, ToolMessage)
|
|
281
|
+
if not isinstance(result, ToolMessage):
|
|
282
|
+
return result
|
|
283
|
+
payload = _text(result.content)
|
|
284
|
+
if payload is None: # no text to judge (an image or file alone)
|
|
281
285
|
return result
|
|
282
286
|
name = request.tool_call.get("name", "")
|
|
283
287
|
cls = self.firewall.config.class_of(name)
|
|
284
288
|
description = str(getattr(getattr(request, "tool", None), "description", "") or "")
|
|
285
289
|
# Everything the judge sees is fixed here, when the tool returned.
|
|
286
|
-
payload = result.content
|
|
287
290
|
window = self._window(request.state.get("messages", []))
|
|
288
291
|
boundary = {
|
|
289
292
|
**self._tool_boundary(name, description),
|
|
@@ -411,11 +414,10 @@ class HumanboundFirewallMiddleware(AgentMiddleware):
|
|
|
411
414
|
window = []
|
|
412
415
|
for message in messages:
|
|
413
416
|
for kind, role in _ROLES:
|
|
414
|
-
if isinstance(message, kind)
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
)
|
|
417
|
+
if isinstance(message, kind):
|
|
418
|
+
text = _text(message.content)
|
|
419
|
+
if text:
|
|
420
|
+
window.append({"role": role, "content": text[: self._history_chars]})
|
|
419
421
|
return window[-self._history_messages :]
|
|
420
422
|
|
|
421
423
|
|
|
@@ -425,6 +427,63 @@ def _tool_name(tool) -> str:
|
|
|
425
427
|
|
|
426
428
|
def _last_human(messages) -> str:
|
|
427
429
|
for message in reversed(list(messages)):
|
|
428
|
-
if isinstance(message, HumanMessage)
|
|
429
|
-
|
|
430
|
+
if isinstance(message, HumanMessage):
|
|
431
|
+
text = _text(message.content)
|
|
432
|
+
if text is not None:
|
|
433
|
+
return text
|
|
430
434
|
return ""
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# The block types that carry no text to judge: the model reads them as media, and their payload
|
|
438
|
+
# is bytes or a URL. Everything else is screened, whatever its type — LangChain keeps document,
|
|
439
|
+
# search_result, json, custom_tool_call_output and file blocks as tool content, a human turn can
|
|
440
|
+
# carry text-plain, and each one reaches the model as text. Listing what to skip rather than what
|
|
441
|
+
# to read means a block type added later is screened by default: over-screening is the safe
|
|
442
|
+
# failure, a block that slips past is not.
|
|
443
|
+
_MEDIA_BLOCKS = frozenset({"image", "image_url", "audio", "video"})
|
|
444
|
+
# Dropped from a block's JSON form: raw media bytes, which carry no text and would swamp the judge.
|
|
445
|
+
_BINARY_FIELDS = frozenset({"base64", "data_base64", "bytes"})
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _text(content) -> str | None:
|
|
449
|
+
"""The text a message carries, whether its content is a string or a list of content blocks.
|
|
450
|
+
|
|
451
|
+
LangChain allows both (`str | list[str | dict]`): multimodal messages and MCP tool results with
|
|
452
|
+
several parts or an image arrive as a list. Plain strings and `{"type": "text"}` blocks are
|
|
453
|
+
joined with newlines, so text split across blocks is judged as one payload. Any other block —
|
|
454
|
+
a document, a search result, JSON, a custom tool's output — is judged as its JSON form, since
|
|
455
|
+
the model reads the text inside it. Pure media blocks are skipped. None when nothing is left.
|
|
456
|
+
"""
|
|
457
|
+
if isinstance(content, str):
|
|
458
|
+
return content
|
|
459
|
+
if not isinstance(content, list):
|
|
460
|
+
return None
|
|
461
|
+
parts = []
|
|
462
|
+
for block in content:
|
|
463
|
+
if isinstance(block, str):
|
|
464
|
+
parts.append(block)
|
|
465
|
+
elif isinstance(block, dict):
|
|
466
|
+
if block.get("type") in _MEDIA_BLOCKS:
|
|
467
|
+
continue
|
|
468
|
+
if block.get("type") == "text" and isinstance(block.get("text"), str):
|
|
469
|
+
parts.append(block["text"])
|
|
470
|
+
else:
|
|
471
|
+
parts.append(_block_json(block))
|
|
472
|
+
parts = [p for p in parts if p]
|
|
473
|
+
return "\n".join(parts) if parts else None
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _block_json(block: dict) -> str:
|
|
477
|
+
"""A non-text block as JSON for the judge, with raw media bytes dropped at any depth."""
|
|
478
|
+
|
|
479
|
+
def strip(value):
|
|
480
|
+
if isinstance(value, dict):
|
|
481
|
+
return {k: strip(v) for k, v in value.items() if k not in _BINARY_FIELDS}
|
|
482
|
+
if isinstance(value, list):
|
|
483
|
+
return [strip(v) for v in value]
|
|
484
|
+
return value
|
|
485
|
+
|
|
486
|
+
try:
|
|
487
|
+
return json.dumps(strip(block), ensure_ascii=False, default=str)
|
|
488
|
+
except (TypeError, ValueError):
|
|
489
|
+
return str(block)
|
{humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/llm/azureopenai.py
RENAMED
|
@@ -8,9 +8,10 @@ from os import getenv
|
|
|
8
8
|
import requests
|
|
9
9
|
|
|
10
10
|
# Same parameter handling as the OpenAI provider (see there for the reasoning): the modern
|
|
11
|
-
# `max_completion_tokens` for every deployment, and `temperature`
|
|
12
|
-
# that names
|
|
13
|
-
|
|
11
|
+
# `max_completion_tokens` for every deployment, and `temperature` / `reasoning_effort` dropped on
|
|
12
|
+
# the 400 that names them. Azure deployment names are arbitrary, so a model-name list could never
|
|
13
|
+
# work here — the drop-and-remember is keyed on the deployment name just the same.
|
|
14
|
+
from .openai import _note_rejected, _sampling_params
|
|
14
15
|
|
|
15
16
|
ALLOWED_MAX_OUT_TOKENS = 4096
|
|
16
17
|
DEFAULT_MAX_OUT_TOKENS = 2048
|
|
@@ -37,6 +38,7 @@ class LLMStreamer:
|
|
|
37
38
|
azure_endpoint=integ.get("endpoint", ""),
|
|
38
39
|
)
|
|
39
40
|
self.model = integ["model"]
|
|
41
|
+
self.reasoning_effort = integ.get("reasoning_effort")
|
|
40
42
|
|
|
41
43
|
def ping(
|
|
42
44
|
self, system_p, user_p, max_tokens=DEFAULT_MAX_OUT_TOKENS, temperature=DEFAULT_TEMPERATURE
|
|
@@ -46,21 +48,26 @@ class LLMStreamer:
|
|
|
46
48
|
{"role": "system", "content": system_p},
|
|
47
49
|
{"role": "user", "content": user_p},
|
|
48
50
|
]
|
|
49
|
-
params = _sampling_params(self.model, max_tokens, temperature)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
51
|
+
params = _sampling_params(self.model, max_tokens, temperature, self.reasoning_effort)
|
|
52
|
+
while True:
|
|
53
|
+
try:
|
|
54
|
+
return self.__client.chat.completions.create(
|
|
55
|
+
model=self.model,
|
|
56
|
+
messages=messages,
|
|
57
|
+
timeout=LLM_PING_TIMEOUT,
|
|
58
|
+
stream=True,
|
|
59
|
+
**params,
|
|
60
|
+
)
|
|
61
|
+
except Exception as e:
|
|
62
|
+
drop = (
|
|
63
|
+
_note_rejected(self.model, str(e), getattr(e, "param", None)) & params.keys()
|
|
64
|
+
if getattr(e, "status_code", None) == 400
|
|
65
|
+
else set()
|
|
66
|
+
)
|
|
67
|
+
if not drop:
|
|
68
|
+
raise
|
|
69
|
+
for name in drop:
|
|
70
|
+
params.pop(name)
|
|
64
71
|
|
|
65
72
|
|
|
66
73
|
class LLMPinger:
|
|
@@ -81,7 +88,9 @@ class LLMPinger:
|
|
|
81
88
|
{"role": "system", "content": system_p},
|
|
82
89
|
{"role": "user", "content": user_p},
|
|
83
90
|
],
|
|
84
|
-
**_sampling_params(
|
|
91
|
+
**_sampling_params(
|
|
92
|
+
integ["model"], max_tokens, temperature, integ.get("reasoning_effort")
|
|
93
|
+
),
|
|
85
94
|
},
|
|
86
95
|
timeout=LLM_PING_TIMEOUT,
|
|
87
96
|
)
|
|
@@ -109,10 +118,8 @@ class LLMPinger:
|
|
|
109
118
|
continue
|
|
110
119
|
raise Exception("502/Rate limit error.")
|
|
111
120
|
elif resp.status_code == 400:
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
_models_without_temperature.add(model)
|
|
115
|
-
continue # retry once, now without temperature
|
|
121
|
+
if _note_rejected(self._provider["integration"]["model"], resp.text):
|
|
122
|
+
continue # retry; the next request omits the param(s) the deployment rejected
|
|
116
123
|
raise Exception(f"502/Inappropriate content ({resp.text}).")
|
|
117
124
|
else:
|
|
118
125
|
raise Exception(f"502/Error pinging LLM - {resp.status_code}/{resp.text}")
|
{humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/llm/models.py
RENAMED
|
@@ -19,6 +19,11 @@ class ProviderIntegration(BaseModel):
|
|
|
19
19
|
model: str
|
|
20
20
|
endpoint: str | None = None
|
|
21
21
|
api_version: str | None = None
|
|
22
|
+
# Reasoning effort for the judge, when the model is a reasoning model that accepts it
|
|
23
|
+
# (e.g. OpenAI "minimal" | "low" | "medium" | "high"). Left None, no effort is sent and
|
|
24
|
+
# the model's own default applies. A model that rejects the parameter has it dropped on
|
|
25
|
+
# the first 400 and remembered, the same way an unsupported temperature is.
|
|
26
|
+
reasoning_effort: str | None = None
|
|
22
27
|
|
|
23
28
|
|
|
24
29
|
class Provider(BaseModel):
|