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
@toolfunction (the tool'sexecute); the@toolfunction's own parameters are task configuration and are not sources; .completion,.messages,.tool_callsand.argumentsanywhere,.output.messageand.output.choices,state.output, the result of any.generate(...)method call such asget_model().generate(...), and parameters annotatedModelOutput(includingModelOutput | NoneandOptional[ModelOutput]). A solver's baregenerate(state)returns the task state, which is not a source as a whole;read_file()andexec()results fromsandbox(...)(however it is imported), from a name or attribute bound to one, or from a parameter or variable annotatedSandboxEnvironment.
Sinks:
- code: the builtins
exec,eval,compileand__import__, called bare or throughbuiltins, andrunpy.run_pathandrunpy.run_module. An import such asfrom inspect_ai import evalrebinds 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-leveltry); a module-level block such as anif __name__ == "__main__":guard and the functions defined in it; or a function and the functions nested in it.exec,compileand__import__stay checked; - data:
pickle,marshal,dillandcloudpickleloadandloads;joblib.load;pandas.read_pickle;numpy.loadwithallow_pickle=anything but a false literal;yaml.unsafe_load,yaml.full_loadand their_allforms;yaml.loadandyaml.load_allwithout a safe loader; andtorch.loadwithoutweights_only=True. A safe loader isSafeLoader,CSafeLoaderorBaseLoader,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.subprocesswith a payload that is a string by how it is written (a literal, f-string, concatenation,%,.formatorstr(...)) or by what it reads (.completion,.text,.stdout,.stderror an awaitedread_file(...)), andsubprocess.run,Popen,call,check_callandcheck_outputwithshell=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 samesubprocesscalls without a shell,pty.spawnandinspect_ai.util.subprocesswith 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*andos.posix_spawn/posix_spawnp, whose program is the first argument; andos.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 examplecommon/tools.py:evalorsolver.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.subprocessgiven 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 evalor a relative import ofevalis 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 asos.system; the__import__call itself is still checked; - an import in a module-level
withblock, such aswith suppress(ImportError):, is treated like one in anif: a builtin rebinding there applies only inside the block; globalandnonlocalwrites 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 throughselforcls; - 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.