Skip to content

IESC002: host_code_execution

Model output, tool arguments and sandbox content are not executed or deserialised on the host.

Category: security · Applies to: eval, helper · Allowlist: [tool.inspect-evals-lint.allowlists.host_code_execution]

What it does

Traces values the model under evaluation controls to calls that run or deserialise them on the machine running the evaluation rather than in the sandbox.

Host code is every Python file under the package that exclude does not rule out. Code shipped into a sandbox (challenge sources, container code, solution scripts) belongs in exclude; this check and the other AST rules then never read it. Within a host file, everything that runs is read: module, function and class bodies, decorators, default arguments and class bases.

Sources, the values a model controls by construction:

  • the parameters of a function defined inside a @tool function (the tool's execute); the @tool function's own parameters are task configuration and are not sources;
  • .completion, .messages, .tool_calls and .arguments anywhere, .output.message and .output.choices, state.output, the result of any .generate(...) method call such as get_model().generate(...), and parameters annotated ModelOutput (including ModelOutput | None and Optional[ModelOutput]). A solver's bare generate(state) returns the task state, which is not a source as a whole;
  • read_file() and exec() results from sandbox(...) (however it is imported), from a name or attribute bound to one, or from a parameter or variable annotated SandboxEnvironment.

Sinks:

  • code: the builtins exec, eval, compile and __import__, called bare or through builtins, and runpy.run_path and runpy.run_module. An import such as from inspect_ai import eval rebinds only the name it imports, and only where it is in force: the whole file when it sits at the top level (or in a top-level try); a module-level block such as an if __name__ == "__main__": guard and the functions defined in it; or a function and the functions nested in it. exec, compile and __import__ stay checked;
  • data: pickle, marshal, dill and cloudpickle load and loads; joblib.load; pandas.read_pickle; numpy.load with allow_pickle= anything but a false literal; yaml.unsafe_load, yaml.full_load and their _all forms; yaml.load and yaml.load_all without a safe loader; and torch.load without weights_only=True. A safe loader is SafeLoader, CSafeLoader or BaseLoader, getattr(yaml, "CSafeLoader", yaml.SafeLoader) where the name and the default are both safe, or a class in the same file that subclasses one. A same-file class is judged by its bases, not its name;
  • shell commands: os.system, os.popen, subprocess.getoutput, subprocess.getstatusoutput, asyncio.create_subprocess_shell, inspect_ai.util.subprocess with a payload that is a string by how it is written (a literal, f-string, concatenation, %, .format or str(...)) or by what it reads (.completion, .text, .stdout, .stderr or an awaited read_file(...)), and subprocess.run, Popen, call, check_call and check_output with shell= anything but a false literal. With a shell and a list, only the first element is the command;
  • programs, reported only when the program or executable= is tainted: the same subprocess calls without a shell, pty.spawn and inspect_ai.util.subprocess with a list or tuple literal, whose program is the first element (or the whole argv when it is written as a string); asyncio.create_subprocess_exec, os.exec* and os.posix_spawn/posix_spawnp, whose program is the first argument; and os.spawn*, whose program follows the mode. A tainted argument to a constant program is not reported;
  • importlib.import_module, only when the module name is tainted.

Only the argument that is run counts: the code of exec, not the namespace passed beside it. Model input handed to constant code as data is not traced.

A module sink is recognised only through a name an import in force binds. Imports at the top level or in a top-level try apply to the whole file. A function's imports apply in that function and the functions nested in it. A module-level block's imports apply inside it, and elsewhere only to names no top-level import binds. A parameter or other local binding hides an imported module in its function: with import os at the top, def f(os): os.system(x) is not the module, and neither is yaml.load after yaml = YAML() in a function.

Propagation is within one file. In a function, a name (or an attribute such as self.code) is tainted if any assignment, loop target, with target, walrus, match capture, default argument or append/update-style call puts a tainted value into it, wherever the sink sits. An expression is tainted if anything in it is, which covers f-strings, concatenation, .format and calls such as str(x). Nested functions see their enclosing function's taint and sandbox bindings. Calls to functions and self or cls methods (static methods included) in the same file are followed one level: tainted arguments, including unpacked *args and **kwargs, taint the callee's parameters, and a callee returning a source taints the call. A callee is analysed in the scope that defines it, with that scope's taint, sandbox bindings and imports. Nothing crosses files.

Statuses:

  • error when a source reaches a sink. The allowlist key is <path within the package>:<sink>, for example common/tools.py:eval or solver.py:subprocess.run;
  • warning for every other shell, code or deserialisation sink in host code, so a reviewer sees each one. Mark a reviewed site with # inspect-evals-lint: ignore[host_code_execution] -- <reason> on any line of the call, where the reason says where its input comes from, e.g. -- constant query code; the model's SQL is passed as data;
  • warning when a process runs from an argv that is not a literal and carries model-controlled input, since the program cannot be told. This includes inspect_ai.util.subprocess given a variable, which may hold a string or a list.

Known limits:

  • an interpreter given code on its command line, such as ["bash", "-c", tool_argument], is a constant program with a tainted argument and is not reported;
  • a same-file def eval or a relative import of eval is still taken for the builtin, and aliasing a builtin by assignment (ev = eval; ev(x)) is not followed;
  • a chained __import__("os").system(x) is not recognised as os.system; the __import__ call itself is still checked;
  • an import in a module-level with block, such as with suppress(ImportError):, is treated like one in an if: a builtin rebinding there applies only inside the block;
  • global and nonlocal writes are not traced from one function to another;
  • a static method called through its class name (H.run(x)) is not followed, only one called through self or cls;
  • a sandbox bound in one method, such as self.sb = sandbox() in __init__, is not seen in another.

Why is this bad?

The sandbox is what stands between the model and the machine running the evaluation. A tool that evals its argument, or a scorer that execs a file the agent wrote, runs model output with the host's permissions, credentials and network: a model can read secrets, alter logs or scores, or hang the run (9**9**9 gets past a digits-and-operators allowlist). Unpickling, yaml.load or torch.load of model-controlled bytes is the same thing.

The analysis is deliberately shallow. It misses taint that crosses files or passes through more than one call, so an error is strong evidence and the absence of one is not proof. A warning is a sink the check could not connect to a source, not a verdict that the site is safe.

Example

@tool
def calculate():
    async def execute(expression: str) -> str:
        return str(eval(expression))

    return execute

Use instead:

@tool
def calculate():
    async def execute(expression: str) -> str:
        result = await sandbox().exec(["python3", "-c", f"print({expression})"])
        return result.stdout

    return execute

Options

  • allowlists.host_code_execution: { package = ["path/within/package.py:sink"] } entries reported as warnings while an existing surface is burned down.

See also

Suppress on a line with # inspect-evals-lint: ignore[IESC002] -- <reason> or ignore[host_code_execution] -- <reason>; select or ignore it in configuration by either, or by the prefix IESC.