<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Security | kaguc — Writing to understand systems.</title><link>https://kaguc.com/tag/security/</link><atom:link href="https://kaguc.com/tag/security/index.xml" rel="self" type="application/rss+xml"/><description>Security</description><generator>Hugo Blox Builder (https://hugoblox.com)</generator><language>en-us</language><lastBuildDate>Wed, 29 Jul 2026 00:00:00 +0000</lastBuildDate><image><url>https://kaguc.com/media/logo.svg</url><title>Security</title><link>https://kaguc.com/tag/security/</link></image><item><title>Executing model-generated code: a two-layer sandbox</title><link>https://kaguc.com/blog/code-sandbox/</link><pubDate>Wed, 29 Jul 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/code-sandbox/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/code-sandbox-zh/">中文&lt;/a>. Part 5 of the Engineering LLM Applications series.&lt;/em>&lt;/p>
&lt;h2 id="the-problem-why-model-written-code-must-be-executed-at-all">The problem: why model-written code must be executed at all&lt;/h2>
&lt;p>One class of content in automated reporting cannot avoid code execution: experiments where only raw data exists — &lt;code>.mat&lt;/code> field-sweep files, &lt;code>csv&lt;/code> band structures — while the existing figures are MATLAB &lt;code>.fig&lt;/code> files that cannot be embedded in a PDF. The LLM can read the data format but cannot draw: turning data into a figure requires &lt;em>computation plus rendering&lt;/em>, and only code does that. So the design is the same as Code Interpreter: the LLM writes a piece of matplotlib code, the backend executes it in a subprocess, out comes a PNG, and compilation embeds it into the report.&lt;/p>
&lt;p>On the agent side this is a single tool, &lt;code>make_figure&lt;/code>, with exactly two parameters: &lt;code>{name, code}&lt;/code>. The agent calls it from its &lt;a href="https://kaguc.com/blog/agent-loop/">tool loop&lt;/a> once it understands the data; on failure it receives the error and may revise the code and retry; on success the report references the figure via &lt;code>\includegraphics{用图/&amp;lt;name&amp;gt;}&lt;/code>. The system prompt sets the rule alongside: if there is data but no figure, plot it — never fabricate images.&lt;/p>
&lt;p>The cost is equally direct: this executes arbitrary Python generated by a probabilistic component on the backend — a built-in RCE entry point. This article dissects the two-layer sandbox designed for it, and the measured results.&lt;/p>
&lt;h2 id="threat-model-the-model-cannot-tell-data-from-instructions">Threat model: the model cannot tell data from instructions&lt;/h2>
&lt;p>The attack chain is clear: an untrusted data folder → file contents enter the prompt → prompt injection → the LLM writes malicious code → the backend executes it. The root cause is that an LLM processes data and instructions in the same token stream (in-band signaling) — structurally the same defect as SQL injection (data mixed into queries) and buffer overflows (data mixed into control flow). It cannot be fixed at the model layer: alignment training lowers the probability but offers no guarantee. The industry&amp;rsquo;s consensus path is therefore to &lt;em>assume the injection will succeed and control the damage&lt;/em>.&lt;/p>
&lt;p>The engineering corollary: the security design must not depend on the assumption that &amp;ldquo;the model won&amp;rsquo;t write bad code.&amp;rdquo; The gates must sit after the code leaves the model and before it has any effect. We built two layers: &lt;strong>static interception before execution&lt;/strong> and &lt;strong>runtime isolation&lt;/strong> — the first blocks known-dangerous patterns before they run, the second assumes the first will leak and minimizes what leaked code can do.&lt;/p>
&lt;h2 id="layer-1-the-ast-allowlist--static-interception-before-execution">Layer 1: the AST allowlist — static interception before execution&lt;/h2>
&lt;p>&lt;code>ast_check&lt;/code> parses the model&amp;rsquo;s code into an AST (a parse failure is rejected as a syntax error) and walks every node; any violation rejects the whole submission:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Import allowlist&lt;/strong> (17 modules): only data/plotting modules — numpy/scipy/matplotlib/pandas/math/csv/json and the like — with both &lt;code>import&lt;/code> and &lt;code>from ... import&lt;/code> compared by top-level package name. The stance is allowlist, not blocklist: the criterion is &amp;ldquo;what does a plotting task need,&amp;rdquo; not &amp;ldquo;what would an attacker use.&amp;rdquo;&lt;/li>
&lt;li>&lt;strong>Dangerous-name blocklist&lt;/strong> (37 names, checked on bare &lt;code>Name&lt;/code> nodes, not just imports): os/sys/subprocess/socket/ctypes/importlib/pickle/pathlib/glob… — any appearance rejects. This rule is not redundant: the execution wrapper pre-imports &lt;code>os&lt;/code> to implement file navigation, so model code can call &lt;code>os.remove&lt;/code> without ever writing &lt;code>import os&lt;/code>. &lt;em>Usage&lt;/em> must be intercepted, not merely &lt;em>importing&lt;/em>.&lt;/li>
&lt;li>&lt;strong>Forbidden builtins&lt;/strong> (17): eval/exec/compile/&lt;code>__import__&lt;/code>/open/getattr/globals… — banned both as calls and as bare-name references, so aliasing like &lt;code>e = eval&lt;/code> is rejected too.&lt;/li>
&lt;li>&lt;strong>Dunder escapes&lt;/strong>: any &lt;code>__xx__&lt;/code> attribute access is rejected, closing the classic &lt;code>().__class__.__subclasses__()&lt;/code> escape chain:&lt;/li>
&lt;/ol>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">elif&lt;/span> &lt;span class="nb">isinstance&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">node&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ast&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Attribute&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">node&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">attr&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">startswith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;__&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">node&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">attr&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">endswith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;__&amp;#34;&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;禁止访问 &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">node&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">attr&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Rejection is not a dead end. The error returned to the agent is instructional: it states which libraries are allowed, that data is read via &lt;code>srcpath('filename')&lt;/code>, that output is saved via &lt;code>plt.savefig(OUT)&lt;/code>, and what is banned. The agent takes this deterministic signal, rewrites, and retries — the same closed-loop pattern as &lt;a href="https://kaguc.com/blog/compile-self-repair/">compile self-repair&lt;/a>: feed the output of a deterministic check back to the model.&lt;/p>
&lt;h2 id="layer-2-runtime-isolation--nothing-left-to-damage-after-a-leak">Layer 2: runtime isolation — nothing left to damage after a leak&lt;/h2>
&lt;p>Layer 1 is static analysis over text and can, in principle, miss. Layer 2 assumes it already has, and shrinks the execution environment to the minimum:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Measure&lt;/th>
&lt;th>Damage it targets&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Environment built from scratch: only the allowlisted variables PATH/MPLBACKEND/MPLCONFIGDIR/HOME/LANG&lt;/td>
&lt;td>API keys and proxy settings drop out automatically instead of leaking via the environment&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>_GUARD&lt;/code> preamble injected before user code: &lt;code>os.system&lt;/code> replaced with a lambda raising PermissionError, &lt;code>sys.modules['subprocess']&lt;/code> set to None, &lt;code>socket.socket&lt;/code> disabled&lt;/td>
&lt;td>Command execution and network egress after a static miss&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Isolated cwd: a fresh &lt;code>mkdtemp&lt;/code> directory per run, deleted afterwards&lt;/td>
&lt;td>Writing into host directories&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>python -I&lt;/code>: isolated mode&lt;/td>
&lt;td>Injection via PYTHONPATH / user site-packages&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>90-second timeout&lt;/td>
&lt;td>Infinite loops and resource exhaustion&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Source-data directory mounted read-only by compose (container deployment)&lt;/td>
&lt;td>Tampering with source data&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Building the environment from scratch has a platform cost: on Windows, a CPython subprocess needs SystemRoot and related system variables (crypto RNG, system DLLs), and matplotlib needs a writable MPLCONFIGDIR/TEMP — these must be explicitly back-filled, or figure generation fails unconditionally in the Windows distribution.&lt;/p>
&lt;p>There is also an easily missed companion design: &lt;strong>every ban must ship with a replacement&lt;/strong>. The allowlist bans os/glob/pathlib, so the model cannot list directories or join paths — offering no substitute would force it into violations. The wrapper therefore injects three navigation primitives, all confined to the source-data directory SRC: &lt;code>srcpath('relative-name')&lt;/code> for paths, &lt;code>listdir(sub='')&lt;/code> for directory listings, and &lt;code>walkfiles()&lt;/code> for a recursive list of all relative filenames; the tool description spells out both constraints and usage so the model writes compliant code on the first attempt. On failure, only the last 1500 characters of stderr are returned — error messages are tokens too.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-gdscript3" data-lang="gdscript3">&lt;span class="line">&lt;span class="cl">&lt;span class="n">flowchart&lt;/span> &lt;span class="n">TD&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">A&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;agent calls make_figure {name, code}&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">--&amp;gt;&lt;/span> &lt;span class="n">B&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;Layer 1: ast_check static allowlist&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">B&lt;/span> &lt;span class="o">--&amp;gt;|&lt;/span>&lt;span class="n">reject&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">E&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;Instructional error: allowed and banned items spelled out&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">E&lt;/span> &lt;span class="o">--&amp;gt;&lt;/span> &lt;span class="n">F&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;agent rewrites code, retries&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">F&lt;/span> &lt;span class="o">--&amp;gt;&lt;/span> &lt;span class="n">B&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">B&lt;/span> &lt;span class="o">--&amp;gt;|&lt;/span>&lt;span class="k">pass&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">W&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;Wrapper: pre-import np/plt/loadmat, inject SRC/OUT/srcpath/listdir/walkfiles + _GUARD&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">W&lt;/span> &lt;span class="o">--&amp;gt;&lt;/span> &lt;span class="n">R&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;Layer 2: isolated subprocess&amp;lt;br/&amp;gt;from-scratch env · isolated cwd · python -I · 90s timeout&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">R&lt;/span> &lt;span class="o">--&amp;gt;|&lt;/span>&lt;span class="s2">&amp;#34;OUT exists and non-empty&amp;#34;&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">P&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;PNG into session figure dir, embedded at compile time&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">R&lt;/span> &lt;span class="o">--&amp;gt;|&lt;/span>&lt;span class="n">failure&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="n">timeout&lt;/span>&lt;span class="o">|&lt;/span> &lt;span class="n">E&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="measured-seven-classes-of-malicious-samples-all-blocked-before-execution">Measured: seven classes of malicious samples, all blocked before execution&lt;/h2>
&lt;p>Verified by real runs inside the container. Two legitimate paths passed: basic matplotlib plotting, and plotting real data read via &lt;code>srcpath&lt;/code>. Seven classes of malicious samples were all rejected by the AST layer before execution:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Malicious sample&lt;/th>
&lt;th>Rule hit&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>os.system&lt;/code> command execution&lt;/td>
&lt;td>Dangerous-name blocklist (os is pre-imported; bare names are intercepted, not imports)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>os.remove&lt;/code> deleting files (no import — uses the pre-imported os)&lt;/td>
&lt;td>Same as above&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>import socket&lt;/code> for egress&lt;/td>
&lt;td>Import allowlist&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>eval&lt;/code> dynamic execution&lt;/td>
&lt;td>Forbidden builtins&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>__import__&lt;/code> bypassing the import statement&lt;/td>
&lt;td>Forbidden builtins&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dunder escape (&lt;code>__class__&lt;/code>/&lt;code>__subclasses__&lt;/code>)&lt;/td>
&lt;td>Dunder attribute interception&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>open&lt;/code> reading /etc/passwd&lt;/td>
&lt;td>Forbidden builtins&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Layer 2 was never reached by this sample set — which is exactly as intended: a backstop&amp;rsquo;s value lies in unknown samples, not in the test set.&lt;/p>
&lt;h2 id="two-unexpected-payoffs-the-security-constraint-pays-back-at-packaging-time">Two unexpected payoffs: the security constraint pays back at packaging time&lt;/h2>
&lt;p>For standalone distribution the backend is frozen into a binary with PyInstaller. Two interactions with the sandbox could have been deep pits; the allowlist made both shallow.&lt;/p>
&lt;p>&lt;strong>Payoff 1: a finite, known import set makes &lt;code>collect_all&lt;/code> a precise fix.&lt;/strong> PyInstaller collects dependencies by static analysis, but the plotting code inside the sandbox is a string that exists only at runtime — whatever it imports is invisible to the packager. With an unbounded import set the problem is unsolvable; the AST allowlist, however, pins it down to a finite known set, so running &lt;code>collect_all&lt;/code> over numpy/scipy/matplotlib and the rest captures everything. Measured on Windows: the frozen onedir output is ~260MB (30MB exe), with selftest reporting imports 10/10 and savefig 18250 bytes — the scientific stack is fully usable inside the frozen bundle.&lt;/p>
&lt;p>&lt;strong>Payoff 2: self-dispatch around the frozen &lt;code>sys.executable&lt;/code>.&lt;/strong> In development the execution command is &lt;code>[sys.executable, &amp;quot;-I&amp;quot;, script]&lt;/code>; after freezing, &lt;code>sys.executable&lt;/code> is this binary rather than python, and &lt;code>exe -I script&lt;/code> would be interpreted as a server launch and fail. The fix: when &lt;code>run_plot&lt;/code> detects &lt;code>is_compiled()&lt;/code>, it instead sets the environment variable &lt;code>PDFAGENT_PYRUN=&amp;lt;script path&amp;gt;&lt;/code> and launches another copy of the same exe; the entry point &lt;code>run_server._pyrun()&lt;/code> sees the variable, executes the script via &lt;code>runpy.run_path&lt;/code>, and exits without starting the server. Process isolation, the scrubbed environment, the timeout, the AST allowlist, and &lt;code>_GUARD&lt;/code> all remain — only the &lt;code>-I&lt;/code> flag is lost. The dispatch is pinned by the two cases in &lt;code>test_pyrun.py&lt;/code> (with the variable set, the script runs; without it, no-op).&lt;/p>
&lt;h2 id="where-not-to-do-this">Where not to do this&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>This is not a full sandbox.&lt;/strong> There is no microVM and no container-level isolation; the subprocess shares the host&amp;rsquo;s kernel and filesystem permissions — a combined attack that bypasses the AST layer &lt;em>and&lt;/em> evades &lt;code>_GUARD&lt;/code> exists in theory. The project states its position in the module docstring: it targets the local single-user scenario (the code plots the user&amp;rsquo;s own local data — equivalent to the user running a script by hand), where two layers suffice; for untrusted folders or multi-user service, the real fix is one-shot, network-less container execution.&lt;/li>
&lt;li>&lt;strong>The allowlist sacrifices expressiveness.&lt;/strong> getattr and open are banned; h5py and similar libraries are not on the list, so HDF5-class data requires explicitly extending the allowlist — and re-assessing the new library&amp;rsquo;s capability surface (can it make network requests, can it write files). Every notch the allowlist widens, the weaker Layer 1&amp;rsquo;s guarantee becomes.&lt;/li>
&lt;li>&lt;strong>Domain restriction is the precondition.&lt;/strong> The approach works because the task domain is narrow: &amp;ldquo;data plotting&amp;rdquo; needs a small, stable module set. For general-purpose code execution — an agent freely writing arbitrary tool scripts — the allowlist would widen until meaningless; go straight to container/microVM isolation, with the AST layer at most as a heuristic pre-check.&lt;/li>
&lt;li>&lt;strong>Layer 2 couples to platform details.&lt;/strong> Building the environment from scratch means discovering every implicit platform dependency yourself: Windows system variables, matplotlib&amp;rsquo;s writable config directory. Projects distributing cross-platform should budget for verification by real runs, not by reasoning.&lt;/li>
&lt;/ol></description></item></channel></rss>