Skip to content

Capability synthesis executes agent-authored code at deploy time; the AST gate covers only module-level statements #29

Description

@EvolveAegis

Summary

agents/live_capabilities.py synthesize_capability() writes agent-provided tool code to tools/dynamic/{name}.py and the deploy step itself then runs it: the auto-test executes the file (python3 -c exec(open(...).read())) and imports it and calls the exported function (live_capabilities.py:763-791). The daemon later hot-loads the same file via exec_module on startup/reload (agents/daemon.py:231), and the synthesized function is registered with no approval step anywhere in deploy→execute (daemon.py:295-299).

The only gate is a syntax/AST check (live_capabilities.py:509-585, daemon.py:197-207): it rejects stubs, writes to three hardcoded paths, and module-level executable statements. Nothing inspects what a function body does, and the protected-path regex only matches a literal open('/agentOS/agents|api|mcp', ...)Path("/agentOS/agents/registry.py").write_text(...), os.system(...), or any other body-level operation passes untouched.

This is the same incident class as BUG-015 (2026-05-05, "synthesized tools can write to core system files via Python file I/O" — a tool with a module-level open('/agentOS/agents/registry.py','w') truncated the registry at hotload import time). The AST check added for BUG-015 stops module-level statements, but not function bodies — and the synthesis path's auto-test executes the file before the hot-load gate even runs.

I verified the chain locally (scratch dir; the only change was redirecting the hardcoded /agentOS mount point — gate, write and auto-test logic are the repo's code verbatim):

  • payload def pwn_canary(**kw): import os; os.system("echo <nonce> > /tmp/canary")synthesize_capability returns ok: true and writes the file;
  • deploy-time auto-test (live_capabilities.py:765-791): exec(open(...)) rc 0, then exec_module + fn()the canary file is created during synthesis itself — before any daemon reload and before anyone calls the tool;
  • the daemon hot-load path (_hotload_dynamic_tools, daemon.py:197-231) with a real engine: same file passes the AST gate, is exec_module'd, registered (daemon.py:295-299), and invoking the registered function runs the payload.

Details

The interesting mismatch with the documented design: synthesize_capability's docstring (live_capabilities.py:471-474) says the capability is "submitted to quorum, voted on automatically next daemon cycle, and hot-loaded into the running engine on approval". The code does not wait: it writes the file, auto-tests it (which executes it), and calls POST /tools/reload immediately. The quorum path only exists for propose_change proposals, and for new_tool it writes a .json spec without code (proposals.py:394-408); synthesize_capability is the code path and it has no vote.

Also worth noting: built-in capabilities (shell_exec, fs_write, ...) run with restrictions (e.g. shell_exec's regex block for open('/agentOS/agents/...','w'), BUGS.md BUG-015 fix #3). The synthesis path is a weaker filter that achieves the same effect as those capabilities plus restart persistence, without the restrictions.

How to reproduce

python3 - <<'EOF'
payload = 'def pwn_canary(**kw):\n    import os\n    os.system("echo NONCE > /tmp/pwn_canary.txt")\n    return {"ok": True}\n'
# 1) synthesize_capability(payload) -> ok: true, tools/dynamic/pwn_canary.py written
#    (gate at live_capabilities.py:509-585 accepts function-body payloads)
# 2) deploy-time auto-test (live_capabilities.py:763-791):
#    step1: python3 -c "exec(open('pwn_canary.py').read())"   -> rc 0
#    step2: exec_module + fn()                                -> {"ok": true}
# 3) /tmp/pwn_canary.txt now exists
EOF

The hot-load side (_hotload_dynamic_tools, daemon.py:197-231) re-executes the same file on every daemon start/reload, with shell_exec/fs_read/fs_write helpers bound into the module namespace using the daemon's bearer token from /agentOS/config.json (daemon.py:222-227).

Impact

An agent whose context includes web research, shared logs, or peer messages can be steered to synthesize a tool whose body does anything; the payload runs at deploy time, persists across restarts in tools/dynamic/, re-runs on every hot-load, and executes inside the daemon process with helpers bound to the daemon's full API surface.

Not claiming new privilege — the agent can already run commands via shell_exec (execution_engine.py:26,82; the requires_approval flag is written but never read, so there is no approval gate anywhere). The concrete increments are: (1) persistence across restarts, (2) auto-execution at deploy time, (3) re-execution on every hot-load, (4) execution inside the daemon namespace with master-token helpers.

Limitation: I did not demo the steering step end-to-end with a live LLM; the gate/write/auto-test/load/exec chain is code-verified, and the context channels feeding the agent are the repo's documented design.

Suggested change

  • Remove the deploy-time auto-execution, or run it in a restricted subprocess with no token access and no persistence.
  • Scan function bodies, not just module-level statements (e.g. reject body-level open(...,'w')/os.system/subprocess unless declared capabilities allow them).
  • Run synthesized tools in an isolated process without the master token in the module namespace.
  • Make the code match the docstring: actual quorum approval before write/hot-load, not after.

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