Skip to content

Commit 1ce6c30

Browse files
committed
fix: path-scoped cycle detection; SafetyGuard list-depth bound (#33)
- _arguments_depth/argumentsDepth: enter/exit bookkeeping so only ancestor back-references are cycles; acyclic shared (DAG) references are allowed, true cycles still fail closed (Greptile P1) - SafetyGuard._nested_content: increment depth for list children so list-only nesting is bounded; deep or cyclic lists return a decision instead of RecursionError (T-Rex P1) Tests: 214 passed; taint 0; black clean; tsc clean
1 parent aa02b8f commit 1ce6c30

4 files changed

Lines changed: 78 additions & 18 deletions

File tree

npm/src/guards.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -370,26 +370,35 @@ export class ToolGuard extends BaseGuard {
370370

371371
/**
372372
* Non-recursive max container nesting depth of a value.
373-
* Returns -1 when a container cycle is detected (fail closed).
373+
* Returns -1 when an ancestor back-reference (true cycle) is detected.
374+
* Containers shared by siblings (acyclic DAG references) are allowed —
375+
* enter/exit bookkeeping keeps the visited set to the active path only.
374376
*/
375377
private static argumentsDepth(obj: any): number {
376378
if (obj === null || typeof obj !== 'object') return 0;
377379
let max = 0;
378-
const stack: Array<[any, number]> = [[obj, 1]];
379-
const seen = new Set<any>([obj]);
380+
type Frame = [any, number, boolean];
381+
const stack: Frame[] = [[obj, 1, true]];
382+
const onPath = new Set<any>();
380383
while (stack.length > 0) {
381-
const pair = stack.pop()!;
382-
const node = pair[0];
383-
const depth = pair[1];
384+
const frame = stack.pop()!;
385+
const node = frame[0];
386+
const depth = frame[1];
387+
const entering = frame[2];
388+
if (!entering) {
389+
onPath.delete(node);
390+
continue;
391+
}
392+
if (onPath.has(node)) return -1;
393+
onPath.add(node);
384394
if (depth > max) max = depth;
395+
stack.push([node, depth, false]);
385396
const children: any[] = Array.isArray(node)
386397
? node
387398
: Object.values(node);
388399
for (const child of children) {
389400
if (child !== null && typeof child === 'object') {
390-
if (seen.has(child)) return -1;
391-
seen.add(child);
392-
stack.push([child, depth + 1]);
401+
stack.push([child, depth + 1, true]);
393402
}
394403
}
395404
}

src/qwed_open_responses/guards/safety_guard.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,10 @@ def _nested_content(self, value: Any, depth: int) -> str:
254254
if isinstance(value, dict):
255255
return self._extract_content(value, depth)
256256
if isinstance(value, list):
257-
collected = [self._nested_content(item, depth) for item in value]
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]
258261
return " ".join(collected)
259262
if isinstance(value, str):
260263
return value

src/qwed_open_responses/guards/tool_guard.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -364,23 +364,32 @@ def _arguments_depth(obj: Any) -> int:
364364
Used to fail closed on deeply-nested dict arguments before
365365
``str(arguments)`` / json.dumps can raise RecursionError (Greptile P1).
366366
Uses an explicit stack, so it never recurses itself. Returns -1 when
367-
a container cycle is detected, so callers fail closed (Greptile P1).
367+
an ancestor back-reference (true cycle) is detected, so callers fail
368+
closed (Greptile P1). Containers shared by siblings (acyclic DAG
369+
references) are allowed — enter/exit bookkeeping keeps the visited
370+
set limited to the active traversal path, not the whole traversal.
368371
"""
369372
if not ToolGuard._is_container(obj):
370373
return 0
371374
max_depth = 0
372-
stack = [(obj, 1)]
373-
seen = {id(obj)}
375+
# (node, depth, entering) frames: entering=False marks the exit of a
376+
# node, so `on_path` holds only true ancestors at any moment.
377+
stack: List[Tuple[Any, int, bool]] = [(obj, 1, True)]
378+
on_path: Set[int] = set()
374379
while stack:
375-
node, depth = stack.pop()
380+
node, depth, entering = stack.pop()
381+
if not entering:
382+
on_path.discard(id(node))
383+
continue
384+
if id(node) in on_path:
385+
return -1
386+
on_path.add(id(node))
376387
if depth > max_depth:
377388
max_depth = depth
389+
stack.append((node, depth, False))
378390
for child in ToolGuard._container_children(node):
379391
if ToolGuard._is_container(child):
380-
if id(child) in seen:
381-
return -1
382-
seen.add(id(child))
383-
stack.append((child, depth + 1))
392+
stack.append((child, depth + 1, True))
384393
return max_depth
385394

386395
@staticmethod

tests/test_guard_envelope_coverage.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,45 @@ def test_cyclic_arguments_fail_closed_no_hang(self):
527527
ok = guard.check({"type": "function_call", "name": "f", "arguments": {"a": {"b": 1}}})
528528
assert ok.passed is True
529529

530+
# ------------------------------------------------------------------
531+
# Shared (acyclic) references between siblings are NOT cycles — only
532+
# ancestor back-edges are rejected (Greptile P1).
533+
# ------------------------------------------------------------------
534+
535+
def test_shared_reference_arguments_not_flagged_as_cycle(self):
536+
shared = {"q": "benign"}
537+
guard = ToolGuard()
538+
result = guard.check({
539+
"type": "function_call",
540+
"name": "f",
541+
"arguments": {"left": shared, "right": shared},
542+
})
543+
assert result.passed is True
544+
assert ToolGuard._arguments_depth({"left": shared, "right": shared}) == 2
545+
# True cycle is still rejected.
546+
cyclic = {"a": 1}
547+
cyclic["self"] = cyclic
548+
assert ToolGuard._arguments_depth(cyclic) == -1
549+
550+
# ------------------------------------------------------------------
551+
# List-only nesting must count toward SafetyGuard's content-depth
552+
# bound — deep or cyclic lists must not raise RecursionError (T-Rex P1).
553+
# ------------------------------------------------------------------
554+
555+
def test_deep_list_nesting_bounded_in_safety_guard(self):
556+
sg = SafetyGuard()
557+
deep = ["leaf"]
558+
for _ in range(1100):
559+
deep = [deep]
560+
# Must return a decision, not raise RecursionError.
561+
result = sg.check({"result": deep})
562+
assert isinstance(result.passed, bool)
563+
# Cyclic list structure must terminate too.
564+
cyc = ["x"]
565+
cyc.append(cyc)
566+
result2 = sg.check({"result": cyc})
567+
assert isinstance(result2.passed, bool)
568+
530569
# ------------------------------------------------------------------
531570
# Multiple top-level tool collections are ambiguous - rejected, not
532571
# double-counted (Sentry LOW)

0 commit comments

Comments
 (0)