Skip to content

Commit 6ca8a83

Browse files
committed
Add CybORG arena
1 parent f854939 commit 6ca8a83

16 files changed

Lines changed: 628 additions & 2 deletions

File tree

.github/mlc_config.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
},
3636
{
3737
"pattern": "https://www\\.contributor-covenant\\.org/version/2/1/code_of_conduct\\.html"
38+
},
39+
{
40+
"pattern": "https://join\\.slack\\.com/t/swe-bench/shared_invite/.*"
3841
}
3942
]
4043
}

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,16 @@ The winner is the LM agent who wins the most rounds.
9898
## 🧩 Available Arenas
9999

100100
CodeClash includes competitive programming games and simulation-backed arenas, including BattleSnake,
101-
CoreWar, Halite, HuskyBench, RoboCode, RobotRumble, and SCML.
101+
CoreWar, CybORG, Halite, HuskyBench, RoboCode, RobotRumble, and SCML.
102102

103103
SCML is a supply-chain negotiation arena based on the ANAC Supply Chain Management League OneShot
104104
track. Agents edit a Python `scml_agent.py` implementation and compete to maximize average profit
105105
across multiple simulated supply-chain worlds.
106106

107+
CybORG is a simulated cyber-defense arena based on the CAGE Challenge 3 DroneSwarm scenario. Agents
108+
edit a Python `cyborg_agent.py` implementation and compete to maximize blue-team reward across
109+
simulated episodes.
110+
107111
## 🚀 Get Involved
108112

109113
- Check out our [docs](https://docs.codeclash.ai/) for more details on running different arenas, configuring tournaments, etc.

codeclash/arenas/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from codeclash.arenas.bridge.bridge import BridgeArena
77
from codeclash.arenas.chess.chess import ChessArena
88
from codeclash.arenas.corewar.corewar import CoreWarArena
9+
from codeclash.arenas.cyborg.cyborg import CybORGArena
910
from codeclash.arenas.dummy.dummy import DummyArena
1011
from codeclash.arenas.figgie.figgie import FiggieArena
1112
from codeclash.arenas.gomoku.gomoku import GomokuArena
@@ -25,6 +26,7 @@
2526
BridgeArena,
2627
ChessArena,
2728
CoreWarArena,
29+
CybORGArena,
2830
DummyArena,
2931
FiggieArena,
3032
GomokuArena,
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
FROM python:3.11-slim-bookworm
2+
3+
ENV DEBIAN_FRONTEND=noninteractive \
4+
PYTHONDONTWRITEBYTECODE=1 \
5+
PIP_NO_CACHE_DIR=1
6+
7+
RUN apt-get update \
8+
&& apt-get install -y --no-install-recommends \
9+
ca-certificates git build-essential jq \
10+
&& rm -rf /var/lib/apt/lists/*
11+
12+
RUN python -m pip install --upgrade pip \
13+
&& git clone https://github.com/cage-challenge/CybORG.git /opt/CybORG \
14+
&& cd /opt/CybORG \
15+
&& git checkout a2d03f99e587af153ae0ac50fb94ba6272e4fff2 \
16+
&& python -m pip install "numpy<1.24" -e /opt/CybORG
17+
18+
WORKDIR /workspace
19+
20+
COPY codeclash/arenas/cyborg/runtime/ /workspace/
21+
22+
RUN git init \
23+
&& git config user.email "player@codeclash.com" \
24+
&& git config user.name "Player" \
25+
&& git add . \
26+
&& git commit -m "Initial CybORG workspace"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from codeclash.arenas.cyborg.cyborg import CybORGArena
2+
3+
__all__ = ["CybORGArena"]

codeclash/arenas/cyborg/cyborg.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import json
2+
import shlex
3+
import subprocess
4+
5+
from codeclash.agents.player import Player
6+
from codeclash.arenas.arena import CodeArena, RoundStats
7+
from codeclash.constants import RESULT_TIE
8+
from codeclash.utils.environment import assert_zero_exit_code
9+
10+
RESULTS_JSON = "cyborg_results.json"
11+
12+
13+
class CybORGArena(CodeArena):
14+
name: str = "CybORG"
15+
submission: str = "cyborg_agent.py"
16+
description: str = """CybORG is a simulated cyber-defense arena based on the CAGE Challenge 3 DroneSwarm scenario.
17+
18+
Your bot is a Python file named `cyborg_agent.py` that defines a class named `MyAgent`.
19+
`MyAgent` should inherit from a CybORG BaseAgent-compatible class, for example:
20+
21+
from CybORG.Agents import RandomAgent
22+
23+
class MyAgent(RandomAgent):
24+
...
25+
26+
Each round evaluates every submitted agent independently on the same seeded DroneSwarm episodes.
27+
Your agent controls the blue-team drone agents through CybORG's simulated PettingZoo interface.
28+
The objective is to maximize average episode reward. This arena uses CybORG simulation only and does
29+
not run real exploit tools or interact with external networks.
30+
"""
31+
default_args: dict = {
32+
"steps_per_episode": 30,
33+
"num_drones": 18,
34+
"timeout": 240,
35+
}
36+
37+
def _game_arg(self, key: str):
38+
return self.game_config.get("args", {}).get(key, self.default_args[key])
39+
40+
def _episodes_per_round(self) -> int:
41+
return int(self.game_config.get("args", {}).get("episodes_per_round", self.game_config["sims_per_round"]))
42+
43+
def validate_code(self, agent: Player) -> tuple[bool, str | None]:
44+
quoted_submission = shlex.quote(self.submission)
45+
file_check = agent.environment.execute(f"test -f {quoted_submission} && echo exists")
46+
if "exists" not in file_check["output"]:
47+
return False, f"Submission file `{self.submission}` not found in the workspace root"
48+
49+
content = agent.environment.execute(f"cat {quoted_submission}")["output"]
50+
if not content.strip():
51+
return False, f"`{self.submission}` is empty"
52+
53+
syntax_check = agent.environment.execute(f"python -m py_compile {quoted_submission}")
54+
if syntax_check["returncode"] != 0:
55+
return False, f"Python syntax error in `{self.submission}`:\n{syntax_check['output']}"
56+
57+
import_check = agent.environment.execute(
58+
"python - <<'PY'\n"
59+
"import importlib.util\n"
60+
f"spec = importlib.util.spec_from_file_location('submission_agent', {self.submission!r})\n"
61+
"module = importlib.util.module_from_spec(spec)\n"
62+
"spec.loader.exec_module(module)\n"
63+
"assert hasattr(module, 'MyAgent'), 'MyAgent class not found'\n"
64+
"from CybORG.Agents import BaseAgent\n"
65+
"assert issubclass(module.MyAgent, BaseAgent), 'MyAgent must inherit from a CybORG BaseAgent class'\n"
66+
"PY"
67+
)
68+
if import_check["returncode"] != 0:
69+
return False, f"Could not import `MyAgent` from `{self.submission}`:\n{import_check['output']}"
70+
71+
return True, None
72+
73+
def execute_round(self, agents: list[Player]) -> None:
74+
agent_args = []
75+
for agent in agents:
76+
agent_args.extend(["--agent", f"{agent.name}=/{agent.name}/{self.submission}"])
77+
78+
cmd = [
79+
"python",
80+
"run_cyborg.py",
81+
"--episodes",
82+
str(self._episodes_per_round()),
83+
"--steps",
84+
str(self._game_arg("steps_per_episode")),
85+
"--drones",
86+
str(self._game_arg("num_drones")),
87+
"--output",
88+
str(self.log_env / RESULTS_JSON),
89+
*agent_args,
90+
]
91+
full_cmd = " ".join(shlex.quote(part) for part in cmd)
92+
self.logger.info(f"Running game: {full_cmd}")
93+
try:
94+
response = self.environment.execute(full_cmd, timeout=int(self._game_arg("timeout")))
95+
except subprocess.TimeoutExpired as exc:
96+
raise RuntimeError("CybORG round timed out") from exc
97+
assert_zero_exit_code(response, logger=self.logger)
98+
99+
def get_results(self, agents: list[Player], round_num: int, stats: RoundStats):
100+
result_file = self.log_round(round_num) / RESULTS_JSON
101+
if not result_file.exists():
102+
self.logger.error(f"Missing result file: {result_file}")
103+
stats.winner = RESULT_TIE
104+
for agent in agents:
105+
stats.scores[agent.name] = 0.0
106+
stats.player_stats[agent.name].score = 0.0
107+
return
108+
109+
with open(result_file) as f:
110+
result = json.load(f)
111+
112+
scores = {agent.name: 0.0 for agent in agents}
113+
for player, score in result.get("average_scores", {}).items():
114+
if player in scores:
115+
scores[player] = float(score)
116+
117+
stats.scores = scores
118+
stats.details = result.get("details", [])
119+
for player, score in scores.items():
120+
stats.player_stats[player].score = score
121+
122+
if not scores:
123+
stats.winner = RESULT_TIE
124+
return
125+
126+
top_score = max(scores.values())
127+
winners = [player for player, score in scores.items() if score == top_score]
128+
stats.winner = winners[0] if len(winners) == 1 else RESULT_TIE
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
__pycache__/
2+
*.py[cod]
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# CybORG CodeClash Workspace
2+
3+
Edit `cyborg_agent.py`.
4+
5+
Your file must define `MyAgent`, a CybORG `BaseAgent` subclass. A safe starting point is:
6+
7+
```python
8+
from CybORG.Agents import RandomAgent
9+
10+
11+
class MyAgent(RandomAgent):
12+
pass
13+
```
14+
15+
The arena runs simulated CAGE Challenge 3 DroneSwarm episodes and scores agents by average reward.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from CybORG.Agents import RandomAgent
2+
3+
4+
class MyAgent(RandomAgent):
5+
"""Baseline CybORG blue-team agent.
6+
7+
Improve this class to choose better defensive actions in the simulated DroneSwarm scenario.
8+
"""
9+
10+
pass
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import argparse
2+
import importlib.util
3+
import json
4+
import random
5+
import re
6+
import traceback
7+
from pathlib import Path
8+
from statistics import mean
9+
10+
import numpy as np
11+
from CybORG import CybORG
12+
from CybORG.Agents import BaseAgent
13+
from CybORG.Agents.Wrappers.PettingZooParallelWrapper import PettingZooParallelWrapper
14+
from CybORG.Simulator.Scenarios import DroneSwarmScenarioGenerator
15+
16+
CRASH_SCORE = -1_000_000.0
17+
18+
19+
def safe_module_name(player_name: str) -> str:
20+
safe = re.sub(r"\W+", "_", player_name)
21+
if not safe or safe[0].isdigit():
22+
safe = f"player_{safe}"
23+
return f"codeclash_cyborg_{safe.lower()}"
24+
25+
26+
def load_agent_class(player_name: str, path: str):
27+
spec = importlib.util.spec_from_file_location(safe_module_name(player_name), path)
28+
if spec is None or spec.loader is None:
29+
raise RuntimeError(f"Could not load module spec from {path}")
30+
module = importlib.util.module_from_spec(spec)
31+
spec.loader.exec_module(module)
32+
if not hasattr(module, "MyAgent"):
33+
raise RuntimeError(f"{path} does not define MyAgent")
34+
agent_class = module.MyAgent
35+
if not issubclass(agent_class, BaseAgent):
36+
raise RuntimeError(f"{path} MyAgent must inherit from CybORG BaseAgent")
37+
return agent_class
38+
39+
40+
def make_agent(agent_class: type, agent_name: str):
41+
try:
42+
return agent_class(name=agent_name)
43+
except TypeError:
44+
try:
45+
return agent_class(agent_name)
46+
except TypeError:
47+
return agent_class()
48+
49+
50+
def evaluate_player(
51+
player_name: str,
52+
agent_class: type,
53+
*,
54+
episode_idx: int,
55+
steps: int,
56+
drones: int,
57+
) -> dict:
58+
seed = 4100 + episode_idx
59+
random.seed(seed)
60+
np.random.seed(seed)
61+
62+
try:
63+
scenario = DroneSwarmScenarioGenerator(num_drones=drones)
64+
env = PettingZooParallelWrapper(CybORG(scenario, "sim"))
65+
observations = env.reset()
66+
action_spaces = env.action_spaces
67+
agents = {agent_name: make_agent(agent_class, agent_name) for agent_name in env.possible_agents}
68+
69+
for agent_name, agent in agents.items():
70+
if hasattr(agent, "set_initial_values"):
71+
agent.set_initial_values(action_spaces[agent_name], observations[agent_name])
72+
73+
step_rewards = []
74+
for _ in range(steps):
75+
actions = {
76+
agent_name: agents[agent_name].get_action(observations[agent_name], action_spaces[agent_name])
77+
for agent_name in env.agents
78+
}
79+
observations, rewards, done, _info = env.step(actions)
80+
step_rewards.append(mean(rewards.values()))
81+
if all(done.values()):
82+
break
83+
84+
for agent in agents.values():
85+
if hasattr(agent, "end_episode"):
86+
agent.end_episode()
87+
88+
return {
89+
"player": player_name,
90+
"episode": episode_idx,
91+
"score": float(sum(step_rewards)),
92+
"steps_completed": len(step_rewards),
93+
"status": "ok",
94+
}
95+
except Exception as exc:
96+
return {
97+
"player": player_name,
98+
"episode": episode_idx,
99+
"score": CRASH_SCORE,
100+
"steps_completed": 0,
101+
"status": "error",
102+
"error": f"{type(exc).__name__}: {exc}",
103+
"traceback": traceback.format_exc(limit=5),
104+
}
105+
106+
107+
def parse_agent_arg(value: str) -> tuple[str, str]:
108+
if "=" not in value:
109+
raise argparse.ArgumentTypeError("--agent values must be NAME=/path/to/cyborg_agent.py")
110+
name, path = value.split("=", 1)
111+
if not name:
112+
raise argparse.ArgumentTypeError("agent name cannot be empty")
113+
if not Path(path).exists():
114+
raise argparse.ArgumentTypeError(f"agent path does not exist: {path}")
115+
return name, path
116+
117+
118+
def main() -> None:
119+
parser = argparse.ArgumentParser()
120+
parser.add_argument("--agent", action="append", type=parse_agent_arg, required=True)
121+
parser.add_argument("--episodes", type=int, default=3)
122+
parser.add_argument("--steps", type=int, default=30)
123+
parser.add_argument("--drones", type=int, default=18)
124+
parser.add_argument("--output", required=True)
125+
args = parser.parse_args()
126+
127+
agent_classes = {name: load_agent_class(name, path) for name, path in args.agent}
128+
totals = {name: 0.0 for name in agent_classes}
129+
details = []
130+
131+
for episode_idx in range(args.episodes):
132+
for player_name, agent_class in agent_classes.items():
133+
result = evaluate_player(
134+
player_name,
135+
agent_class,
136+
episode_idx=episode_idx,
137+
steps=args.steps,
138+
drones=args.drones,
139+
)
140+
totals[player_name] += result["score"]
141+
details.append(result)
142+
143+
averages = {player: score / args.episodes for player, score in totals.items()}
144+
output = {
145+
"average_scores": averages,
146+
"total_scores": totals,
147+
"episodes": args.episodes,
148+
"details": [json.dumps(item, sort_keys=True) for item in details],
149+
}
150+
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
151+
Path(args.output).write_text(json.dumps(output, indent=2, sort_keys=True))
152+
153+
154+
if __name__ == "__main__":
155+
main()

0 commit comments

Comments
 (0)