Repository navigation
feat(renderer): two-step exec mode for plotly and bokeh (PB-1) - #12484
Conversation
The renderer's plotly and bokeh rows render in two steps; nothing serves them before PB-2. - harness.py figure mode (`--figure <package> plot.py -- <screenshot command>`): the run's CPU and file limits, then a fork before anything is armed. The child sets RLIMIT_AS and RLIMIT_NPROC, arms PlotGuard with the kernel exec filter first, runs the plot, which may write only plot-<theme>.json, and ends with os._exit. The parent is never armed: it checks the figure (regular file, not empty, at most 16 MiB) and starts the JavaScript row's harness on it in the sandbox's environment without the plot step's variables, with the package's directory found by find_spec. A report pipe carries the filter state, written before any plot code. Plot-step failures end stderr with the JS-2 final error line (plot, figure, crash, harness); the end line carries both steps' usage. - plotly's CODE_BUILDERS entry: _plotly_utils/basevalidators.py build_validator (plotly 7.1.0), which evaluates an info array item's validator class name. - render.mjs `--library=<plotly|bokeh> --figure=<file>`, sandbox mode only: the figure read without following links, capped, parsed and shape-checked, carried as a JSON element and drawn with the installed package's own plotly.js or BokehJS (gl, widgets, tables; no MathJax), under the sandbox page rules with a stricter policy (scripts by a nonce of the page). The figure's own CSS size picks the orientation and the device scale makes the canvas exactly 3200x1800 or 2400x2400; a bokeh layout is measured first. WebGL traces fail as draw NoWebGL instead of drawing plotly's notice. The sandbox page rules move into shared helpers, unchanged for the snippet path. - commands.py RuntimeCommand.figure and the two rows (Python limits and filter for the plot step, JS_ENV for the screenshot step); executor argv and env. - The renderer image installs lib-plotly and lib-bokeh without kaleido, choreographer, Selenium and their dependencies, and trims plotly's Jupyter bundles and bokeh's compiler, ES sources and unminified bundles (+69 MB). - ci-image.yml renders a plotly and a bokeh figure through the rows. - Tests: test_figure_mode.py (51 cases, no plotly needed) and test_js_render_figure.py (35 cases through Node and Chromium). - Docs: the plan's "What PB-1 built", agent-network.md, agents/README.md, the changelog fragment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Every place the harness pauses its guard was checked for code the snippet or plot defined. Two ran there and wrote files the armed guard refuses: - data mode's print_snippet_error read exc.__traceback__ with the guard paused, which the snippet's exception class can make a property; it now reads the traceback through BaseException's own descriptor, as figure mode's _error_record does. - plot mode's writes_elsewhere ran os.path.realpath on the open event's path with the guard paused; a path that subclasses str (passed directly or returned by __fspath__) had its split and startswith called there. The path is now copied into an exact str (str.join, no method of the subclass runs) while the guard is armed. The frame-name comparisons in caller(), print_snippet_error and _error_record take exact str only (plain_name), so no str subclass's __eq__ or __format__ runs paused. Class names and messages stay outside the pause, judged by the armed guard. Regression tests: three str-subclass routes in PLOT_ROUTES, the __traceback__ property and an armed __str__ in data mode, and the property in figure mode; the first two payloads write their marker on the previous harness. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
There was a problem hiding this comment.
🔵 Needs a closer look
Human security and operational review remains necessary because the change spans forked sandboxing, Chromium/CSP containment, process-group cleanup, and production gVisor behavior that is deferred to deployment smoke testing.
No open issues 🎉
What changed in this PR
This pull request adds an isolated two-step Plotly and Bokeh renderer: guarded Python code produces a JSON figure, and an unarmed JavaScript renderer draws it using bundled library assets. It also hardens paused-guard handling and adds comprehensive tests, documentation, image dependencies, and CI coverage.
Changes:
- Added figure-mode orchestration, validation, sizing, CSP protection, resource limits, and process isolation.
- Added Plotly/Bokeh renderer commands, executor wiring, Docker dependencies, and CI smoke tests.
- Prevented unsafe user-defined traceback and path behavior while guards are paused.
- Updated documentation, specifications, and changelog entries.
Review findings:
agents/anyplot/render/harness.py— Moderate: distinguish an unavailable exec filter from a user plot exiting with the same status code.agents/renderer/commands.py— Moderate: include the figure rows’ Node toolchain in renderer availability checks so missing Node reportsruntime_unavailable.automation/js-render/render.mjs— Moderate: preserve a supplied Plotly width or height when the other dimension is absent, or reject partial dimensions explicitly.
This pull request is too complex or risky for automated approval.
| File | Reviewed changes |
|---|---|
tests/unit/automation/test_js_render_figure.py |
Tests JavaScript figure rendering, validation, CSP behavior, sizing, and Plotly/Bokeh flows. |
tests/unit/agents/runtime/test_harness_guard.py |
Tests paused-guard traceback and path-handling protections. |
tests/unit/agents/renderer/test_figure_mode.py |
Tests two-step execution, process isolation, limits, failures, timeouts, command rows, and executor integration. |
docs/concepts/agent-network.md |
Documents the two-step renderer design, security restrictions, sizing, and current serving status. |
changelog.d/pb1-two-step-exec.md |
Records the new renderer mode and guard-hardening fixes. |
automation/js-render/render.mjs |
Implements figure ingestion, bundled-library rendering, CSP protection, sizing, validation, and screenshots. |
agents/renderer/executor.py |
Wires figure-mode arguments and combines Python and JavaScript renderer settings. |
agents/renderer/Dockerfile |
Installs Plotly/Bokeh runtime assets and removes unused export dependencies. |
agents/renderer/commands.py |
Defines Plotly/Bokeh figure-mode command rows, limits, environments, and metadata. |
agents/README.md |
Updates the renderer architecture overview with figure-mode behavior. |
agents/anyplot/render/harness.py |
Implements forked figure execution, guarded JSON output, screenshot execution, reporting, and guard hardening. |
agentic/specs/261024-agent-all-libraries.md |
Records PB-1 implementation details, constraints, measurements, and test coverage. |
.github/workflows/ci-image.yml |
Adds renderer-image Plotly/Bokeh smoke tests and dependency checks. |
Review effort: Lite
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
- An EXEC_DENY_EXIT from the plot step is the harness's ExecDenyUnavailable only when the report pipe's first line, written before any plot code, says `refused <state>`; after `armed <state>` a plot that ended with code 3 itself (sys.exit(3), os._exit(3)) fails as a `plot` error, whatever the filter's state. The verdict is plot_step_verdict, the pipe's parse parse_figure_report (FigureReport). - A render or page job is checked against its row, not only its language (executor.serves): plotly's and bokeh's rows name Node for the screenshot step, so a renderer without Node answers 503 runtime_unavailable before any sandbox starts instead of exit 127. - render.mjs: a plotly layout that names one side keeps it and gets the other at 16:9 (width 1200 draws at 1200 x 675) instead of both being replaced by 1600 x 900; the derived side is bounds-checked too. - In-process tests for the figure mode's logic, which the subprocess cases run where coverage does not see them: the parent's verdict and its read of the report (os.fork and os.wait4 stood in), the child's codes and report (os._exit, the guard and the plot stood in), the error record, the screenshot step's start, environment and exit codes, run_figure's order and stops, the end line after a raising step. The fork, the arming and the kernel filter stay in the subprocess cases. Patch coverage of the PB-1 diff measured locally: 90 % (harness.py 249 of 278 lines). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Guard hardening found during PB-1's reviews (Fable: F6 high; Flash: the descriptor flags). F6, import trust in the plot guard: a plot that drove the import machinery itself (a loader of its own, or a stock loader on a file it wrote) ran a module body with an import's trust, which read the frames up to the guard and disarmed it. Three rules now hold: - A: an `open` by a file loader's get_data or zipimport's archive reader (IMPORT_CODE_READERS) is refused outside the install (install_prefixes: stdlib, lib-dynload, the stdlib zip, site packages, from sysconfig before arming), whoever drives the loader. - B: an import earns trust only when its first import frame is the interpreter's _call_with_frames_removed under the exec_module or create_module of exactly SourceFileLoader, SourcelessFileLoader, ExtensionFileLoader or zipimporter (instance state only, file in the install), under BuiltinImporter, or is FrozenImporter.exec_module (ImportMachinery). A path hook or finder that is a builtin earns none. - C: the `import` event _imp.create_dynamic raises with an extension module's file is refused outside the install, before native code loads. fcntl.* is refused in plot mode and os.set_blocking (no event) replaced (PLOT_UNAUDITED_CALLS): stdout and stderr are one open file description across figure mode's fork. F7 and the walk, in the validator: matplotlib's bundled dateutil handed out pkgutil.get_data and six's spec_from_loader, and the re-exported types module new_class, ModuleType and LambdaType. dateutil and six are banned as re-exports; get_data, the spec builders, new_class, prepare_class and ModuleType as helpers; LambdaType with FunctionType. A walk over every public attribute of the admitted modules is now a test. disable_unaudited_calls takes the calls to replace, resolved at call time so a test's monkeypatch of UNAUDITED_CALLS still applies. Tests: the F6 routes (source, bytecode and zip loaders on a plot-written file, the loader class of an admitted module, Fable's loader of its own, an instance override of get_code, exec as a path hook, an extension module the plot wrote), pkgutil.get_data past its package, fcntl and set_blocking on stdout, positive lazy imports in plot and figure mode, ImportMachinery's decisions, the validator walk and the routes it found. Measured: the 1,946 catalogue files of the six served Python libraries render alike under the previous and this harness in the renderer image (1,771 render, 175 fail under both), with no fcntl or set_blocking call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
The audit hooks are defense in depth behind the validator, the kernel's exec filter (seccomp with TSYNC, inherited by every child), the resource limits and on Cloud Run the per-job gVisor sandbox (spikes S and S2: no egress, no service environment or tokens, a read-only root filesystem without --write, sibling isolation, one job's lifetime). Stated in the harness's module docstring and in docs/concepts/agent-network.md after the threat table; the plan's PB-1 section names the in-process layer's known limits, F8 (plot code reaching the harness's own objects) and cffi's dlopen (no audit event), both of which the validator's bans cover. No structural change for F8. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
…intact Fable's review of the F6 fix measured a bypass of the trust rule: a get_code set on SourceFileLoader itself, then a stock loader with a clean instance on a stdlib file, ran the plot's module body with an import's trust (no audit event sees a class attribute change, and layer A never fires because the patched get_code reads nothing). A source_to_code set on the class does the same. ImportMachinery now reads the four stock loaders' classes before arming (class_entries: the method resolution order and each mutable class's own entries, through type's own descriptors) and trusted_loader requires the class to be intact: the same MRO, every entry bound to the same object, no entry added or removed, but the two Python 3.14 adds when annotations are first read (LAZY_CLASS_ENTRIES). C-level reads and identity comparisons only. Patching the machinery deeper (_bootstrap._call_with_frames_removed, the module globals of _frozen_importlib_external, builtins.compile) is F8's kind and knowingly out of the in-process layer's reach; the validator is the barrier there, as the docstring, the test comment and the plan now say. Tests: both class-level routes (each wrote the marker through the previous harness, measured) and ImportMachinery's check against a set, an added and a deleted class entry, a swapped base and lazy annotation entries. Measured: the 1,946 catalogue files of the six served Python libraries render in the renderer image exactly as under the previous harness (1,771 render, 175 fail, the same refusals). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
|
Copilot's three medium findings from the review summary are fixed in f93fe6c: (1) exit code 3 is the exec filter's only after the report pipe says |
…t DL4006) `du -shc ... | tail -n 1` failed CI's Hadolint step (DL4006, failure threshold warning). `du -shc` alone prints both packages and the total (94M before, 68M after), so the pipe goes and the file needs no SHELL change. The comment now names render.mjs's FIGURES map, which holds the bundles since PB-1. Checked: hadolint 2.15.1 (the action's pin) passes the file at the warning threshold and fails HEAD's; the image builds, and the CI job's figure-mode and JavaScript render steps pass in it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Summary
harness.py --figureforks the plot's child before anything is armed; the child arms PlotGuard and the kernel exec filter, runs the plot and may write onlyplot-<theme>.json; the parent, never armed, checks the file (regular,O_NOFOLLOW, ≤ 16 MiB) and runsrender.mjs --figureunder the JavaScript row's limits, which draws the figure as data with the installed package's own plotly.js 4.1.1 / BokehJS 3.10.1. The figure page admits scripts only by a per-page nonce (no'unsafe-inline'), so bokehCustomJSand markup handlers in aDivcannot run; network is blocked by route, proxy and--network none. One host timeout kills both steps (process group). The canvas is the figure's own size times a device scale, so catalogue layouts keep their type sizes at exactly 3200x1800 / 2400x2400. No kaleido, choreographer or Selenium in the image (+69 MB).exc.__traceback__while paused (a snippet exception class with a__traceback__property ran unguarded), and plot mode resolved astr-subclass path while paused. Both read through C-level descriptors / exactstrnow.SourceLoader, or a stockSourceFileLoader/SourcelessFileLoader/zipimporteron a file it wrote,ExtensionFileLoaderon a plot-written.so) ran its code with the guard's import exemption and could disarm the guard. Now: (A) the import machinery reads code only under the install prefixes captured fromsysconfigbefore arming; (B) import trust needs the interpreter's own trampoline under an exact stock loader whose class is unchanged since arming and whose instance holds only its own keys; (C) animportaudit event naming a file outside the install (native loads) is refused.fcntlandos.set_blockingare refused in plot mode. Validator:dateutil/sixre-exports,get_data, the import-spec builders andtyping.types.new_classare refused; a reachability walk over everything the validator admits is now a test.docs/concepts/agent-network.md: PlotGuard is defense in depth behind the validator, the seccomp exec filter, the rlimits and the per-job gVisor sandbox (no egress, no tokens). Patching the interpreter's machinery deeper from inside the process (__main__,_frozen_importlib_external) is a known limit of any in-process audit hook (PEP 578); the validator is the barrier there.sys.exit(3)is a plot error); render and page jobs are checked per row, so a figure job on a renderer without Node gets503 runtime_unavailable; a plotly figure with onlywidthorheightkeeps it and derives the other at 16:9.Plan
agentic/specs/261024-agent-all-libraries.md, step 4 and "What PB-1 built (2026-10-31)".Test plan
pytest tests/unit tests/integration -n auto(23,083 passed, 27 skipped), changelog check; JS figure tests on Node 24 (41 passed)--network none --read-only, 2 CPU, 4 GiB, RLIMIT_AS 1 GiB, exec filter required): 20/20 figure renders, plotly 1.9–2.4 s, bokeh 1.6–1.9 s; gVisor (runsc-ptrace) 12/12get_code/source_to_codepatches) wrote its marker on the previous harness and is refused now, as regression testshttps://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8