Skip to content

Commit aa02b8f

Browse files
committed
fix: cyclic arguments fail closed instead of hanging (#33)
argumentsDepth/_arguments_depth re-enqueued container children without tracking identities, so a self-referential arguments object looped forever in ToolGuard.check. Track visited containers and return -1 on cycle detection; both call sites (Python check/_parse_tool_arguments, TS parseToolArguments) fail closed on negative depth. Verified: cyclic direct+wrapper paths return blocked GuardResults in both languages, acyclic args still pass, 212 pytest passed, black clean, tsc clean, taint 0.
1 parent cb556d7 commit aa02b8f

3 files changed

Lines changed: 43 additions & 6 deletions

File tree

npm/src/guards.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -368,11 +368,15 @@ export class ToolGuard extends BaseGuard {
368368

369369
private static MAX_ARGS_JSON_DEPTH = 128;
370370

371-
/** Non-recursive max container nesting depth of a value. */
371+
/**
372+
* Non-recursive max container nesting depth of a value.
373+
* Returns -1 when a container cycle is detected (fail closed).
374+
*/
372375
private static argumentsDepth(obj: any): number {
373376
if (obj === null || typeof obj !== 'object') return 0;
374377
let max = 0;
375378
const stack: Array<[any, number]> = [[obj, 1]];
379+
const seen = new Set<any>([obj]);
376380
while (stack.length > 0) {
377381
const pair = stack.pop()!;
378382
const node = pair[0];
@@ -383,6 +387,8 @@ export class ToolGuard extends BaseGuard {
383387
: Object.values(node);
384388
for (const child of children) {
385389
if (child !== null && typeof child === 'object') {
390+
if (seen.has(child)) return -1;
391+
seen.add(child);
386392
stack.push([child, depth + 1]);
387393
}
388394
}
@@ -402,7 +408,9 @@ export class ToolGuard extends BaseGuard {
402408
if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) {
403409
// Bound structural depth before JSON.stringify can overflow the
404410
// stack on deeply nested objects (Greptile P1, mirror of Python).
405-
if (ToolGuard.argumentsDepth(raw) > ToolGuard.MAX_ARGS_JSON_DEPTH) {
411+
// A negative depth means a cycle — fail closed on that too.
412+
const argsDepth = ToolGuard.argumentsDepth(raw);
413+
if (argsDepth < 0 || argsDepth > ToolGuard.MAX_ARGS_JSON_DEPTH) {
406414
return { ok: false };
407415
}
408416
return { ok: true, value: raw };

src/qwed_open_responses/guards/tool_guard.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -269,8 +269,10 @@ def check(
269269
},
270270
)
271271

272-
# Check for dangerous patterns in arguments
273-
if ToolGuard._arguments_depth(arguments) > ToolGuard._MAX_ARGS_JSON_DEPTH:
272+
# Check for dangerous patterns in arguments. A negative depth
273+
# means a cycle was detected — fail closed on it too.
274+
args_depth = ToolGuard._arguments_depth(arguments)
275+
if args_depth < 0 or args_depth > ToolGuard._MAX_ARGS_JSON_DEPTH:
274276
return self.fail_result(
275277
"BLOCKED: Tool arguments exceed maximum nesting depth.",
276278
details={"tool": tool_name},
@@ -361,18 +363,23 @@ def _arguments_depth(obj: Any) -> int:
361363
362364
Used to fail closed on deeply-nested dict arguments before
363365
``str(arguments)`` / json.dumps can raise RecursionError (Greptile P1).
364-
Uses an explicit stack, so it never recurses itself.
366+
Uses an explicit stack, so it never recurses itself. Returns -1 when
367+
a container cycle is detected, so callers fail closed (Greptile P1).
365368
"""
366369
if not ToolGuard._is_container(obj):
367370
return 0
368371
max_depth = 0
369372
stack = [(obj, 1)]
373+
seen = {id(obj)}
370374
while stack:
371375
node, depth = stack.pop()
372376
if depth > max_depth:
373377
max_depth = depth
374378
for child in ToolGuard._container_children(node):
375379
if ToolGuard._is_container(child):
380+
if id(child) in seen:
381+
return -1
382+
seen.add(id(child))
376383
stack.append((child, depth + 1))
377384
return max_depth
378385

@@ -388,7 +395,8 @@ def _parse_tool_arguments(raw: Any) -> Tuple[bool, Any]:
388395
if raw is None or (isinstance(raw, str) and not raw.strip()):
389396
return True, {}
390397
if isinstance(raw, dict):
391-
if ToolGuard._arguments_depth(raw) > ToolGuard._MAX_ARGS_JSON_DEPTH:
398+
depth = ToolGuard._arguments_depth(raw)
399+
if depth < 0 or depth > ToolGuard._MAX_ARGS_JSON_DEPTH:
392400
return False, None
393401
return True, raw
394402
if isinstance(raw, str):

tests/test_guard_envelope_coverage.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,27 @@ def test_deeply_nested_dict_arguments_fail_closed(self):
506506
r2 = guard.check({"type": "function_call", "name": "f", "arguments": deep_obj})
507507
assert r2.passed is False
508508

509+
# ------------------------------------------------------------------
510+
# Cyclic (self-referential) arguments must fail closed, never hang
511+
# in the depth traversal (Greptile P1).
512+
# ------------------------------------------------------------------
513+
514+
def test_cyclic_arguments_fail_closed_no_hang(self):
515+
cyclic = {"a": 1}
516+
cyclic["self"] = cyclic
517+
guard = ToolGuard()
518+
# Depth traversal itself terminates with the cycle marker.
519+
assert ToolGuard._arguments_depth(cyclic) == -1
520+
# direct tool_call path
521+
r1 = guard.check({"type": "function_call", "name": "f", "arguments": cyclic})
522+
assert r1.passed is False
523+
# OpenAI wrapper path
524+
r2 = guard.check({"function": {"name": "f", "arguments": cyclic}})
525+
assert r2.passed is False
526+
# Acyclic arguments remain valid.
527+
ok = guard.check({"type": "function_call", "name": "f", "arguments": {"a": {"b": 1}}})
528+
assert ok.passed is True
529+
509530
# ------------------------------------------------------------------
510531
# Multiple top-level tool collections are ambiguous - rejected, not
511532
# double-counted (Sentry LOW)

0 commit comments

Comments
 (0)