Skip to content

Commit 666281b

Browse files
committed
Add anti-reward hacking controls (no internet, git history purging)
1 parent a920206 commit 666281b

4 files changed

Lines changed: 124 additions & 0 deletions

File tree

codeclash/agents/minisweagent.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from codeclash.agents.player import Player
1212
from codeclash.agents.utils import GameContext
1313
from codeclash.utils.environment import copy_to_container
14+
from codeclash.utils.internet_control import turn_off_internet, turn_on_internet
1415

1516
os.environ["MSWEA_MODEL_RETRY_STOP_AFTER_ATTEMPT"] = "90"
1617
os.environ["LITELLM_MODEL_REGISTRY_PATH"] = str(
@@ -55,6 +56,16 @@ def run(self):
5556
logger=self.logger,
5657
**self.config["config"]["agent"],
5758
)
59+
# Disable the container's internet DURING the agent's editing turn so it can't look up
60+
# solutions online (setup fetch + post-round push happen outside run(), with net on).
61+
# Composes with Player._isolate_git (which removes opponent branches). Default on.
62+
no_internet = self.config.get("no_internet", True)
63+
if no_internet:
64+
try:
65+
turn_off_internet(self.environment)
66+
except Exception as e:
67+
self.logger.critical(f"FAILED to disable internet — agent runs WITH network: {e}")
68+
no_internet = False # nothing to restore later
5869
exit_status = None
5970
exc_message = None
6071
try:
@@ -65,6 +76,11 @@ def run(self):
6576
exc_message = traceback.format_exc()
6677
self.logger.critical(exc_message)
6778
finally:
79+
if no_internet:
80+
try:
81+
turn_on_internet(self.environment)
82+
except Exception as e:
83+
self.logger.error(f"Failed to restore internet after agent turn: {e}")
6884
traj_path = (
6985
self.game_context.log_local
7086
/ "players"

codeclash/agents/player.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,13 @@ def __init__(
101101
logger=self.logger,
102102
)
103103

104+
# SECURITY: the container's clone carries every opponent's branch + round tags
105+
# (origin/human/*, <uuid>-round-N) — an agent can `git show` them to copy solutions.
106+
# After the starting codebase is checked out, strip git so there is no path to opponent
107+
# code during the agent's turn. Local commits still work; push re-adds origin transiently.
108+
if self.push:
109+
self._isolate_git()
110+
104111
# --- Main methods ---
105112

106113
def pre_run_hook(self, *, new_round: int) -> None:
@@ -143,10 +150,15 @@ def post_run_hook(self, *, round: int) -> None:
143150
self._write_changes_to_file(round=round)
144151

145152
if self.push:
153+
token = os.getenv("GITHUB_TOKEN")
146154
force = " --force-with-lease" if self._force_push else ""
155+
# origin is absent during the agent's turn (see _isolate_git); re-add it only to push,
156+
# then remove it again so the next round's agent still can't reach opponent code.
147157
for cmd in [
158+
f"git remote add origin https://x-access-token:{token}@github.com/{GH_ORG}/{self.game_context.name}.git",
148159
f"git push{force} -u origin {self._branch_name}",
149160
"git push origin --tags",
161+
"git remote remove origin",
150162
]:
151163
assert_zero_exit_code(self.environment.execute(cmd), logger=self.logger)
152164
self.logger.info(f"Pushed {self.name} commit history to remote repository (branch {self._branch_name})")
@@ -214,6 +226,20 @@ def _get_commit_hash(self) -> str:
214226
)
215227
return out["output"].strip()
216228

229+
def _isolate_git(self) -> None:
230+
"""Strip the git remote + all remote-tracking refs and tags so the agent cannot read
231+
opponents' pushed code (origin/human/*, <uuid>-round-N). The local working branch and its
232+
history remain, so the agent can still commit; push (post_run_hook) re-adds origin briefly.
233+
Best-effort — failures here must not abort a run."""
234+
for cmd in [
235+
"git remote remove origin",
236+
"git for-each-ref --format='%(refname)' refs/remotes refs/tags | xargs -r -n1 git update-ref -d",
237+
"git reflog expire --expire=now --all",
238+
"git gc --prune=now --quiet",
239+
]:
240+
self.environment.execute(cmd)
241+
self.logger.info("Isolated git: removed origin + opponent branches/tags (anti-cheat)")
242+
217243
def _commit(self) -> None:
218244
"""Commit changes to the agent's codebase."""
219245
r = self.game_context.round
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Toggle a running container's internet access via iptables in its network namespace.
2+
3+
Ported from RevEngBench (reveng/utils/environment_internet_control.py), Linux-only path.
4+
Used to disable internet DURING the agent's editing turn (so it can't look up solutions),
5+
while leaving it on for setup (git fetch of the starting codebase) and the post-round push.
6+
Loopback is always kept up, so localhost gameplay (bot servers on 0.0.0.0:PORT) is unaffected.
7+
DNS is left reachable so container tools fail fast instead of hanging on resolution.
8+
9+
Host-side (nsenter + sudo iptables) so an in-container root agent cannot undo it. Composes with
10+
Player._isolate_git — internet-off stops re-fetching, git-strip hides the already-cloned branches.
11+
"""
12+
13+
import os
14+
import subprocess
15+
import threading
16+
17+
from minisweagent.environments.docker import DockerEnvironment
18+
19+
from codeclash.utils.log import get_logger
20+
21+
logger = get_logger(__name__)
22+
23+
_dns_lock = threading.Lock()
24+
_container_dns: dict[str, set[str]] = {}
25+
26+
27+
def _docker_pid(env: DockerEnvironment) -> str:
28+
return subprocess.run(
29+
[env.config.executable, "inspect", "--format", "{{.State.Pid}}", env.container_id],
30+
capture_output=True,
31+
text=True,
32+
check=True,
33+
).stdout.strip()
34+
35+
36+
def _iptables(env: DockerEnvironment, rule_args: list[str]) -> None:
37+
"""Run one iptables command in the container's net namespace (sudo nsenter on Linux)."""
38+
pid = _docker_pid(env)
39+
prefix = [] if os.getuid() == 0 else ["sudo", "-n"]
40+
subprocess.run([*prefix, "nsenter", "-t", pid, "-n", "iptables", *rule_args], check=True)
41+
42+
43+
def _dns_servers(env: DockerEnvironment) -> set[str]:
44+
r = env.execute({"command": "grep '^nameserver' /etc/resolv.conf | awk '{print $2}'"})
45+
return {ip.strip() for ip in r["output"].splitlines() if ip.strip()}
46+
47+
48+
def turn_off_internet(env: DockerEnvironment) -> None:
49+
"""Drop all inbound/outbound traffic except loopback and DNS."""
50+
dns = _dns_servers(env)
51+
with _dns_lock:
52+
_container_dns[env.container_id] = dns
53+
for chain in ("OUTPUT", "INPUT"):
54+
_iptables(env, ["-I", chain, "-j", "DROP"])
55+
_iptables(env, ["-I", "OUTPUT", "-o", "lo", "-j", "ACCEPT"])
56+
_iptables(env, ["-I", "INPUT", "-i", "lo", "-j", "ACCEPT"])
57+
for ip in sorted(dns):
58+
_iptables(env, ["-I", "OUTPUT", "-d", ip, "-p", "udp", "--dport", "53", "-j", "ACCEPT"])
59+
_iptables(env, ["-I", "OUTPUT", "-d", ip, "-p", "tcp", "--dport", "53", "-j", "ACCEPT"])
60+
_iptables(env, ["-I", "INPUT", "-s", ip, "-p", "udp", "--sport", "53", "-j", "ACCEPT"])
61+
_iptables(env, ["-I", "INPUT", "-s", ip, "-p", "tcp", "--sport", "53", "-j", "ACCEPT"])
62+
logger.info("Internet disabled for container %s (loopback+DNS kept)", env.container_id[:12])
63+
64+
65+
def turn_on_internet(env: DockerEnvironment) -> None:
66+
"""Remove the drop rules inserted by turn_off_internet (restores full connectivity)."""
67+
with _dns_lock:
68+
dns = _container_dns.pop(env.container_id, set())
69+
for ip in sorted(dns):
70+
_iptables(env, ["-D", "OUTPUT", "-d", ip, "-p", "udp", "--dport", "53", "-j", "ACCEPT"])
71+
_iptables(env, ["-D", "OUTPUT", "-d", ip, "-p", "tcp", "--dport", "53", "-j", "ACCEPT"])
72+
_iptables(env, ["-D", "INPUT", "-s", ip, "-p", "udp", "--sport", "53", "-j", "ACCEPT"])
73+
_iptables(env, ["-D", "INPUT", "-s", ip, "-p", "tcp", "--sport", "53", "-j", "ACCEPT"])
74+
_iptables(env, ["-D", "OUTPUT", "-o", "lo", "-j", "ACCEPT"])
75+
_iptables(env, ["-D", "INPUT", "-i", "lo", "-j", "ACCEPT"])
76+
for chain in ("OUTPUT", "INPUT"):
77+
_iptables(env, ["-D", chain, "-j", "DROP"])
78+
logger.info("Internet restored for container %s", env.container_id[:12])

configs/ladder/ladder_prompt.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,7 @@ game_description: |-
1313
1414
Your goal is to beat the opponent. The game's rules and your bot's entry point are documented in the
1515
codebase (`docs/`).
16+
17+
Rules: build and improve the bot yourself, using only the code, assets, and documentation already in
18+
{{working_dir}}. Internet use is NOT allowed — do not attempt web searches, downloads, package
19+
installs, or any external network requests (the environment has no internet access during your turn).

0 commit comments

Comments
 (0)