Skip to content

fix: reject agent_main rebinding that lets a decoy pass static anti-cheat screening - #207

Closed
kai392 wants to merge 2 commits into
Autovara:mainfrom
kai392:fix/critical-issue-agent-main-rebinding-bypass
Closed

fix: reject agent_main rebinding that lets a decoy pass static anti-cheat screening#207
kai392 wants to merge 2 commits into
Autovara:mainfrom
kai392:fix/critical-issue-agent-main-rebinding-bypass

Conversation

@kai392

@kai392 kai392 commented Jul 29, 2026

Copy link
Copy Markdown

Closes #206.

Root cause

screen_bundle_miner_contract validates the definition find_module_function_def resolves, but the runner executes whatever the module global agent_main is bound to after import. #151 closed only the plain duplicate-def route by counting defs in module_tree.body. A module global is equally rebound by an assignment, an import ... as, a loop/with/except target, a def nested in module-level control flow, a global declaration, a decorator, or namespace mutation (globals(), setattr, exec) — none of which that check can see.

Consequence: every contract and anti-cheat check (no-arg invocation, vulnerabilities report shape, async rejection, and any subnet static_screen) inspects a function the sandbox never calls, so a submission can show real analysis code to screening and execute a canned report — the shape of the incidents in #86 and #122.

Every route below passes screening with zero findings on main (36b2e77):

route before after
agent_main = _canned passes bundle.agent_main_rebound
def nested in if / try / while / with / match passes bundle.agent_main_rebound
from helpers.canned import report as agent_main passes bundle.agent_main_rebound
loop / with / except target, tuple or starred unpacking, walrus passes bundle.agent_main_rebound
class agent_main: shadowing the function passes bundle.agent_main_rebound
global agent_main rebind from a nested scope passes bundle.agent_main_rebound
star import whose helper exports agent_main passes bundle.agent_main_rebound
globals()["agent_main"] = ... / globals().update(...) / vars()[...] passes bundle.agent_main_rebound
setattr(sys.modules[__name__], "agent_main", ...), incl. from a helper passes bundle.agent_main_rebound
exec("agent_main = ...") passes bundle.agent_main_rebound
@decorator on agent_main passes bundle.agent_main_decorated
plain duplicate def (#151) rejected rejected (unchanged)

Fix approach

Require the inspected definition to be the only binding of the name the module ever produces.

  • kata/ast_utils.pyiter_module_scope_nodes walks what Python executes in module scope: it descends into if/try/for/while/with/match bodies, which run at import time, and stops at nested function and class scopes, which bind locally. count_module_scope_name_bindings counts every binding form on top of it; has_module_scope_star_import and declares_global_name cover the two indirect routes; rebinds_name_dynamically covers namespace mutation.
  • kata/screening/rules.py — reject more than one module-scope binding, reject namespace mutation that could reach the entrypoint, and reject a decorated entrypoint.

Two deliberate narrowings keep the rules precise rather than blunt:

  • a star import counts only when another module in the same bundle actually exports agent_main;
  • a namespace write with a constant key other than the entrypoint (globals()["CACHE"] = {}) provably cannot rebind it and stays allowed, while a computed key or an opaque update(...) argument is unknowable statically and fails closed.

Because namespace mutation reaches the module global from any scope — a helper called at import time works just as well — that one check spans the whole bundle instead of module scope only.

No new dependencies, nothing outside these two modules plus tests, and the existing bundle.agent_main_duplicate rule id and message are untouched so the bot's handling is unchanged.

Impact and risk

Makes the static contract binding rather than advisory: the function screening validates is now provably the one the runner imports.

False positives are the real risk here, so the rules are scope-aware and tested from both directions. These all stay legal and are covered: methods and inner defs named agent_main, bare annotations (agent_main: Callable), if TYPE_CHECKING: blocks, try/except ImportError fallbacks, decorated helpers, __all__/__main__ guards, and namespace writes that cannot reach the entrypoint. Both bundles currently in kings/ pass unchanged.

An honest submission that today decorates its entrypoint would need to inline the wrapper. That is the intended tightening — a decorator's return value is by definition not the inspected body.

Scope boundary worth stating plainly: this closes the routes a submission can take silently. Static screening cannot bound arbitrary runtime reflection in general, but it can ensure that replacing the entrypoint requires code no reviewer would read as ordinary indirection.

Verification

python -m ruff check kata tests clean; python -m pytest 80 passed (14 new regression tests — one per route, plus the must-still-pass cases).

Screening resolves agent_main with find_module_function_def and validates that
definition, but the runner executes whatever the module global is bound to after
import. Autovara#151 closed only the plain duplicate-def case: a decoy def plus a
module-level assignment, an import alias, a def nested in module-level control
flow, a `global` rebind, a shadowing star import, or a decorator all still let a
submission pass every contract and anti-cheat check while executing a function
screening never saw -- the exact route a canned/hardcoded-answer agent needs.

Require the inspected definition to be the only module-scope binding of the name:
count bindings scope-aware (descending into module-level control flow, stopping
at nested function and class scopes), and reject a decorated entrypoint. Nested
defs and methods named agent_main bind in their own scope and stay allowed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kai392

kai392 commented Jul 29, 2026

Copy link
Copy Markdown
Author

Both workflow runs on this PR are held at action_required (first-time fork contributor), so I reproduced the two CI jobs locally on python:3.12-slim — the same Python and both exact commands — against this branch's HEAD (c69af47):

=== Lint (ruff) ===        python -m ruff check kata tests
All checks passed!         ruff_exit=0

=== Test (pytest) ===      python -m pytest
75 passed in 0.52s         (66 on main + 9 new regression tests)

=== validate-submission === python tools/check_submission.py --all
no submissions found       check_exit=0

Ready for a maintainer to approve the workflow runs whenever convenient.

The binding-count check only sees the name in a binding position, so the same
decoy still worked through `globals()["agent_main"] = ...`, `globals().update(...)`,
`setattr(sys.modules[__name__], "agent_main", ...)` and `exec("agent_main = ...")` --
one line away from the routes just closed. These reach the module namespace from any
scope (a helper called at import time works too), so they are checked across the whole
bundle rather than in module scope only.

Narrow by construction: a namespace write with a constant key other than the entrypoint
(`globals()["CACHE"] = {}`) provably cannot rebind it and stays allowed, while a computed
key or an opaque `update(...)` argument is not knowable statically and fails closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@carlos4s

Copy link
Copy Markdown
Member

It is already fixed.

@carlos4s carlos4s closed this Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Static screening validates a decoy agent_main when the module rebinds it (assignment, nested def, decorator, star import)

3 participants