Skip to content

Commit 6e9a883

Browse files
authored
Merge pull request #33 from QWED-AI/fix/fail-closed-verification-27-28-29
fix(core): fail-closed on zero guards + ToolGuard shape coverage + SafetyGuard recursive extraction (#27, #28, #29)
2 parents b460d70 + 1f740a1 commit 6e9a883

11 files changed

Lines changed: 1863 additions & 42 deletions

File tree

npm/package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

npm/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,6 @@
6464
"qs": "^6.15.2",
6565
"path-to-regexp": "^8.4.0",
6666
"brace-expansion": "^5.0.8",
67-
"fast-uri": "^3.1.5"
67+
"fast-uri": "^3.1.6"
6868
}
6969
}

npm/src/guards.ts

Lines changed: 432 additions & 10 deletions
Large diffs are not rendered by default.

npm/src/verifier.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,25 @@ export class ResponseVerifier {
3131
const guardsToUse = guards ?? this.defaultGuards;
3232
const parsedResponse = this.parseResponse(response);
3333

34+
// Fail-closed: zero guards must never produce verified=true (#27).
35+
if (guardsToUse.length === 0) {
36+
return {
37+
verified: false,
38+
response: parsedResponse,
39+
guardsPassed: 0,
40+
guardsFailed: 0,
41+
guardResults: [{
42+
guardName: 'ResponseVerifier',
43+
passed: false,
44+
message: 'No guards configured — verification cannot be performed. Pass at least one guard or set defaultGuards.',
45+
severity: 'error',
46+
}],
47+
blocked: this.strictMode,
48+
blockReason: 'No guards configured — fail-closed (zero-guard verify).',
49+
timestamp: new Date().toISOString(),
50+
};
51+
}
52+
3453
const guardResults: GuardResult[] = [];
3554
let guardsPassed = 0;
3655
let guardsFailed = 0;

src/qwed_open_responses/core.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
It orchestrates multiple guards to ensure responses are safe and correct.
66
"""
77

8-
from typing import Any, Dict, List, Optional, Union
8+
from typing import Any, Dict, List, Optional
99
from dataclasses import dataclass, field
1010
from datetime import datetime
1111
import json
@@ -141,6 +141,27 @@ def verify(
141141
# Parse response if needed
142142
parsed_response = self._parse_response(response)
143143

144+
# Fail-closed: zero guards must never produce verified=True (#27).
145+
# Absence of verification is not success — it is the opposite.
146+
if not guards_to_use:
147+
return VerificationResult(
148+
verified=False,
149+
response=parsed_response,
150+
guards_passed=0,
151+
guards_failed=0,
152+
guard_results=[
153+
GuardResult(
154+
guard_name="ResponseVerifier",
155+
passed=False,
156+
message="No guards configured — verification cannot be performed. "
157+
"Pass at least one guard or set default_guards.",
158+
severity="error",
159+
)
160+
],
161+
blocked=self.strict_mode,
162+
block_reason="No guards configured — fail-closed (zero-guard verify).",
163+
)
164+
144165
# Run all guards
145166
guard_results: List[GuardResult] = []
146167
guards_passed = 0

src/qwed_open_responses/guards/safety_guard.py

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,53 @@ def check(
183183

184184
return self.pass_result(message="All safety checks passed")
185185

186-
def _extract_content(self, response: Dict) -> str:
187-
"""Extract text content from response."""
186+
_MAX_CONTENT_DEPTH = 12
187+
_KNOWN_CONTENT_KEYS = ("content", "output", "text", "arguments")
188+
189+
def _extract_content(self, response: Dict, _depth: int = 0) -> str:
190+
"""Extract text content from response — recursively (#29).
191+
192+
Walks all string values at any nesting depth (bounded to prevent
193+
DoS on deeply-nested payloads) so the guard can see content inside
194+
the canonical OpenAI shape (choices[].message.content), Anthropic
195+
envelopes, and arbitrary nested structures.
196+
"""
197+
parts = self._known_content_parts(response)
198+
199+
if _depth < self._MAX_CONTENT_DEPTH:
200+
for key, value in response.items():
201+
if not self._should_traverse(key, value):
202+
continue
203+
parts.append(self._nested_content(value, _depth + 1))
204+
205+
return " ".join(parts)
206+
207+
def _should_traverse(self, key: str, value: Any) -> bool:
208+
"""Decide whether a response entry still needs recursive scanning.
209+
210+
- Unknown keys holding strings ARE scanned (nested scalars must be
211+
checked for injection/PII — Greptile P1).
212+
- Known content keys had their string forms collected verbatim above;
213+
their container forms are traversed so nothing hides inside them.
214+
- output/arguments dicts were already stringified above — skipping
215+
avoids duplicate collection.
216+
"""
217+
if isinstance(value, str):
218+
# content/output/text strings were collected verbatim above;
219+
# a string under 'arguments' was NOT (only dicts are) and must
220+
# still be scanned for injection/PII.
221+
if key == "arguments":
222+
return True
223+
return key not in self._KNOWN_CONTENT_KEYS
224+
if isinstance(value, dict):
225+
return key not in ("output", "arguments")
226+
if isinstance(value, list):
227+
return True
228+
return False # other scalars carry no scannable text
229+
230+
@staticmethod
231+
def _known_content_parts(response: Dict) -> List[str]:
232+
"""Collect strings from the well-known top-level content keys."""
188233
parts = []
189234

190235
if isinstance(response.get("content"), str):
@@ -200,7 +245,23 @@ def _extract_content(self, response: Dict) -> str:
200245
if isinstance(response.get("arguments"), dict):
201246
parts.append(str(response["arguments"]))
202247

203-
return " ".join(parts)
248+
return parts
249+
250+
def _nested_content(self, value: Any, depth: int) -> str:
251+
"""Recursively collect strings from unrecognized nesting levels."""
252+
if depth > self._MAX_CONTENT_DEPTH:
253+
return ""
254+
if isinstance(value, dict):
255+
return self._extract_content(value, depth)
256+
if isinstance(value, list):
257+
# Increment depth for list children too — otherwise list-only
258+
# nesting never reaches _MAX_CONTENT_DEPTH and a deeply (or
259+
# cyclically) nested list recurses until RecursionError (T-Rex P1).
260+
collected = [self._nested_content(item, depth + 1) for item in value]
261+
return " ".join(collected)
262+
if isinstance(value, str):
263+
return value
264+
return ""
204265

205266
def _check_pii(self, content: str) -> List[str]:
206267
"""Check for PII in content."""

0 commit comments

Comments
 (0)