Skip to content

Commit bbdbf70

Browse files
authored
Merge branch 'main' into fix-repo-adr-cve-2025-58367-deepdiff
2 parents 6815d26 + 5f5a74a commit bbdbf70

3 files changed

Lines changed: 184 additions & 12 deletions

File tree

Detection/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ uv run python main_benchmark.py --benchmark agentdojo --concurrent 3
227227
- `concurrency_efficiency`: How well concurrency was utilized
228228
- `overall_mcp_ratio`: Percentage of MCP vs built-in tool usage
229229

230-
**AgentDojo Ground Truth (**`ground_truth.json`**)**:
230+
**AgentDojo Ground Truth** (`ground_truth.json`):
231231

232232
- `is_malicious`: True if attack succeeded, False if defended
233233
- `security`: AgentDojo's security field (False = attack succeeded)

Detection/main_benchmark.py

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
import asyncio
1010
import json
1111
import logging
12+
import os
1213
import random
1314
import re
1415
import shutil
16+
import signal
1517
import sys
1618
import time
1719
from datetime import datetime
@@ -675,11 +677,6 @@ async def execute_ads_task(self, task: Dict[str, Any], workspace_dir: Path,
675677
capabilities = config.get("capabilities", [])
676678
mcp_tools.extend([f"mcp__{server}__{tool}" for tool in capabilities])
677679

678-
claude_projects_dir = Path.home() / ".claude" / "projects"
679-
existing_sessions = set()
680-
if claude_projects_dir.exists():
681-
existing_sessions = {f.name for f in claude_projects_dir.rglob("*.jsonl")}
682-
683680
cmd = self.command_builder.build_claude_command(task, mcp_tools)
684681

685682
success, error_message, result = await self._execute_command(cmd, workspace_dir)
@@ -689,9 +686,7 @@ async def execute_ads_task(self, task: Dict[str, Any], workspace_dir: Path,
689686

690687
execution_time = time.time() - start_time
691688

692-
return await self._process_results(
693-
workspace_dir, existing_sessions, result, execution_time
694-
)
689+
return await self._process_results(workspace_dir, result, execution_time)
695690

696691
except Exception as e:
697692
print(f"❌ Error executing task: {e}")
@@ -781,12 +776,17 @@ async def execute_agentdojo_task(self, task: Dict[str, Any], output_dir: Path) -
781776

782777
async def _execute_command(self, cmd: List[str], workspace_dir: Path) -> Tuple[bool, Optional[str], Optional[Dict[str, Any]]]:
783778
"""Execute Claude CLI command."""
779+
process = None
784780
try:
785781
process = await asyncio.create_subprocess_exec(
786782
*cmd,
787783
cwd=workspace_dir,
788784
stdout=asyncio.subprocess.PIPE,
789-
stderr=asyncio.subprocess.PIPE
785+
stderr=asyncio.subprocess.PIPE,
786+
# Puts the CLI and everything it spawns (MCP servers, etc.) in
787+
# one process group, so a timeout can signal all of them at
788+
# once instead of just the direct child. POSIX only.
789+
start_new_session=(os.name == "posix"),
790790
)
791791

792792
stdout, stderr = await asyncio.wait_for(
@@ -802,11 +802,48 @@ async def _execute_command(self, cmd: List[str], workspace_dir: Path) -> Tuple[b
802802
return True, None, result
803803

804804
except asyncio.TimeoutError:
805+
# asyncio.wait_for() only cancels the await - it does not touch the
806+
# child process. Killing just the direct child isn't enough either:
807+
# it doesn't reach any descendants the CLI spawned (e.g. MCP
808+
# servers), and if one of those descendants inherited the CLI's
809+
# stdout/stderr pipes, it can hold them open indefinitely -
810+
# asyncio's subprocess transport only reports completion once
811+
# those pipes close, so process.wait() could hang forever instead
812+
# of just leaking. Signal the whole process group instead: SIGTERM
813+
# first for a clean shutdown, then SIGKILL if it hasn't exited
814+
# within the grace period.
815+
if process is not None and process.returncode is None:
816+
await self._kill_process_tree(process)
805817
return False, "Task execution timed out", None
806818
except Exception as e:
807819
return False, str(e), None
808820

809-
async def _process_results(self, workspace_dir: Path, existing_sessions: set,
821+
async def _kill_process_tree(self, process: "asyncio.subprocess.Process", grace_period: float = 5.0) -> None:
822+
"""Terminate a process and everything it spawned.
823+
824+
Falls back to killing just the direct child on platforms without
825+
process-group support (e.g. Windows), matching create_subprocess_exec's
826+
start_new_session=(os.name == "posix") above.
827+
"""
828+
if not hasattr(os, "killpg"):
829+
process.kill()
830+
await process.wait()
831+
return
832+
833+
try:
834+
os.killpg(process.pid, signal.SIGTERM)
835+
except ProcessLookupError:
836+
return
837+
838+
try:
839+
await asyncio.wait_for(process.wait(), timeout=grace_period)
840+
except asyncio.TimeoutError:
841+
try:
842+
os.killpg(process.pid, signal.SIGKILL)
843+
except ProcessLookupError:
844+
pass
845+
846+
async def _process_results(self, workspace_dir: Path,
810847
result: Dict[str, Any], execution_time: float) -> Tuple[bool, Optional[str], Optional[Dict[str, Any]]]:
811848
"""Process execution results using session ID from Claude CLI JSON output."""
812849
session_id = result.get("session_id")

Detection/tests/test_main_benchmark.py

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
"""Tests for main_benchmark helpers."""
22

3+
import asyncio
4+
import os
5+
import sys
6+
import time
37
from pathlib import Path
48
from unittest.mock import MagicMock
59

610
import pytest
711

8-
from main_benchmark import CommandBuilder, Config, MCPServerManager, TaskManager
12+
from main_benchmark import CommandBuilder, Config, MCPServerManager, TaskExecutor, TaskManager
913

1014

1115
class TestConfig:
@@ -101,3 +105,134 @@ def test_rejects_zero_concurrency(self):
101105
with pytest.raises(ValueError, match="max_concurrent_tasks must be >= 1"):
102106
if max_concurrent < 1:
103107
raise ValueError(f"max_concurrent_tasks must be >= 1, got {max_concurrent}")
108+
109+
110+
class TestTaskExecutorExecuteCommand:
111+
"""Covers _execute_command's process lifecycle, in particular that a timed-out
112+
claude CLI subprocess is actually killed rather than left running (previously
113+
asyncio.wait_for's timeout only cancelled the await, not the child process)."""
114+
115+
def _make_executor(self, tmp_path: Path, monkeypatch, max_execution_time: float) -> TaskExecutor:
116+
monkeypatch.chdir(tmp_path)
117+
config = Config()
118+
config._config["execution"]["max_execution_time"] = max_execution_time
119+
return TaskExecutor(config)
120+
121+
@pytest.mark.asyncio
122+
async def test_kills_process_on_timeout(self, tmp_path: Path, monkeypatch):
123+
executor = self._make_executor(tmp_path, monkeypatch, max_execution_time=0.05)
124+
125+
real_create_subprocess_exec = asyncio.create_subprocess_exec
126+
spawned = {}
127+
128+
async def spying_create_subprocess_exec(*args, **kwargs):
129+
process = await real_create_subprocess_exec(*args, **kwargs)
130+
spawned["process"] = process
131+
return process
132+
133+
monkeypatch.setattr(asyncio, "create_subprocess_exec", spying_create_subprocess_exec)
134+
135+
cmd = [sys.executable, "-c", "import time; time.sleep(5)"]
136+
success, error_message, result = await executor._execute_command(cmd, tmp_path)
137+
138+
assert success is False
139+
assert "timed out" in error_message.lower()
140+
assert result is None
141+
142+
# The process must have been killed and reaped, not left running in the
143+
# background after _execute_command returns.
144+
process = spawned["process"]
145+
assert process.returncode is not None
146+
147+
@staticmethod
148+
def _wait_until_process_gone(pid: int, timeout: float = 3.0) -> bool:
149+
"""Poll until a pid is fully reaped rather than asserting immediately -
150+
os.kill(pid, 0) on a not-yet-reaped zombie still succeeds, so a single
151+
check right after sending the kill signal can be a false negative."""
152+
deadline = time.monotonic() + timeout
153+
while time.monotonic() < deadline:
154+
try:
155+
os.kill(pid, 0)
156+
except ProcessLookupError:
157+
return True
158+
except PermissionError:
159+
return False
160+
time.sleep(0.1)
161+
return False
162+
163+
@pytest.mark.asyncio
164+
@pytest.mark.skipif(os.name != "posix", reason="process-group kill is POSIX-only")
165+
async def test_kills_grandchild_process_holding_inherited_stdio(self, tmp_path: Path, monkeypatch):
166+
"""A descendant that inherits the CLI's stdout/stderr pipes must also be
167+
killed on timeout - process.kill() alone only signals the direct child,
168+
and a surviving descendant holding those pipes open can block
169+
process.wait() indefinitely instead of just leaking (see PR #20 review)."""
170+
executor = self._make_executor(tmp_path, monkeypatch, max_execution_time=0.2)
171+
172+
real_create_subprocess_exec = asyncio.create_subprocess_exec
173+
spawned = {}
174+
175+
async def spying_create_subprocess_exec(*args, **kwargs):
176+
process = await real_create_subprocess_exec(*args, **kwargs)
177+
spawned["process"] = process
178+
return process
179+
180+
monkeypatch.setattr(asyncio, "create_subprocess_exec", spying_create_subprocess_exec)
181+
182+
pidfile = tmp_path / "grandchild.pid"
183+
# The direct child spawns its own child ("grandchild") without
184+
# redirecting its stdout/stderr, so it inherits the same pipes
185+
# asyncio set up for the direct child - reproducing the scenario
186+
# where a descendant keeps those pipes open past the timeout.
187+
script = (
188+
"import subprocess, sys, time\n"
189+
"gc = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)'])\n"
190+
"open(sys.argv[1], 'w').write(str(gc.pid))\n"
191+
"time.sleep(30)\n"
192+
)
193+
cmd = [sys.executable, "-c", script, str(pidfile)]
194+
195+
start = time.monotonic()
196+
success, error_message, result = await executor._execute_command(cmd, tmp_path)
197+
elapsed = time.monotonic() - start
198+
199+
assert success is False
200+
assert "timed out" in error_message.lower()
201+
202+
# Must return promptly, not hang for anywhere near the grandchild's
203+
# 30s sleep - proves process.wait() wasn't blocked on inherited pipes.
204+
assert elapsed < 10
205+
206+
process = spawned["process"]
207+
assert process.returncode is not None
208+
209+
for _ in range(30):
210+
if pidfile.exists():
211+
break
212+
await asyncio.sleep(0.1)
213+
assert pidfile.exists(), "grandchild never reported its pid"
214+
215+
grandchild_pid = int(pidfile.read_text())
216+
assert self._wait_until_process_gone(grandchild_pid), "grandchild process was left running"
217+
218+
@pytest.mark.asyncio
219+
async def test_returns_parsed_json_on_success(self, tmp_path: Path, monkeypatch):
220+
executor = self._make_executor(tmp_path, monkeypatch, max_execution_time=30)
221+
222+
cmd = [sys.executable, "-c", "import json, sys; sys.stdout.write(json.dumps({'session_id': 'abc123'}))"]
223+
success, error_message, result = await executor._execute_command(cmd, tmp_path)
224+
225+
assert success is True
226+
assert error_message is None
227+
assert result == {"session_id": "abc123"}
228+
229+
@pytest.mark.asyncio
230+
async def test_reports_nonzero_exit_without_leaving_error_message_empty(self, tmp_path: Path, monkeypatch):
231+
executor = self._make_executor(tmp_path, monkeypatch, max_execution_time=30)
232+
233+
cmd = [sys.executable, "-c", "import sys; sys.stderr.write('boom'); sys.exit(1)"]
234+
success, error_message, result = await executor._execute_command(cmd, tmp_path)
235+
236+
assert success is False
237+
assert "boom" in error_message
238+
assert result is None

0 commit comments

Comments
 (0)