Skip to content

Add a run_python dialog tool backed by an nsjail sandbox - #268

Merged
iamwavecut merged 5 commits into
mainfrom
feat/python-sandbox
Oct 20, 2026
Merged

iamwavecut merged 5 commits into
mainfrom
feat/python-sandbox

Conversation

@iamwavecut

@iamwavecut iamwavecut commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

What

The dialog model gets a run_python tool. It runs a model-written Python script in a disposable nsjail sandbox, can copy chat files into it, and sends the files the script creates back to the chat. Typical uses: bar charts and plots, floor plans, tables from a CSV or a crawled page, file conversions, ffmpeg edits, exact calculations.

The script is kept in the stored tool result, so a follow-up such as "make the bars blue" edits the earlier code instead of starting over.

Design notes: docs/superpowers/specs/2026-10-20-python-sandbox-design.md.

How

  • openplotva-sandbox crate. A supervisor binary plus the HTTP client the bot uses.
    • Each call is one nsjail process: no network, uid 65534, 256 MiB cgroup memory without swap, 1 CPU, 30 s, 16 pids, read-only system mounts, and a syscall deny list.
    • The session home is a 100 MiB tmpfs. A session closes 60 s after its last call or 5 min after it opened; at most 4 exist at once.
    • API: POST /v1/sessions/{key}/run, GET /v1/sessions/{key}/files/{path}, DELETE /v1/sessions/{key}, GET /health. File access never follows symlinks.
  • sandbox/ image. nsjail 3.6 is built from source. CPython 3.14 gets a pinned package set (requirements.in → requirements.txt) at build time, Debian's standard ffmpeg is installed, and the matplotlib font cache is prebuilt.
  • Tool (crates/openplotva-app/src/python_sandbox.rs).
    • It resolves file_ids only when the file was seen in the same chat and downloads them.
    • After a clean exit it sends send_files: photos (albums of up to 10), .gif as animations, .mp4 as videos, .mp3/.m4a as audio, and other files as documents. A photo Telegram rejects is resent as a document.
    • Sent messages go into chat history and telegram_files.
    • Errors map to python_error, python_timeout, python_killed, sandbox_busy and sandbox_unavailable.
  • Offering the tool. DialogToolbox::offers_tool hides run_python unless SANDBOX_URL is set. SAFE runtime virtual dialogs run the script but withhold delivery.
  • History. Stored run_python tool results render the call's code next to its output.
  • Prompt. One line in prompts/aifarm/system.prompt sends data charts, plans, tables, file conversions and exact calculations to run_python instead of draw_image, and tells the model to edit the earlier script for a fix-up.
  • Telegram builders. openplotva-telegram gains builders for sendDocument, sendVideo and sendAnimation uploads.
  • Deploy.
    • The compose file adds a privileged sandbox service on an internal network shared only with the app, plus SANDBOX_URL for the app.
    • The deploy workflow builds the sandbox binary and pushes …/openplotva-sandbox:<sha>.
    • The deploy script pulls the image, starts the sandbox, waits for its health and runs a smoke script before it recreates the app. Old local images and GHCR versions of both packages are cleaned up.

Verification

  • cargo fmt --all -- --check, cargo clippy --locked --workspace --all-targets --all-features -- -D warnings, cargo test --locked --workspace, tools/rust-dependency-gate.sh: pass.
  • Local sandbox image under Docker (privileged, cgroup v2):
    • Isolation: uid 65534, no network or DNS, read-only venv and root, no /proc, unshare/mount denied, the 16th process fails with EAGAIN, files over 100 MiB fail with EFBIG, and the home fills at 100 MiB.
    • Limits: a 400 MB allocation ends as killed, and an endless loop ends as timeout after 30 s.
  • The tool ran end to end against the real supervisor, with fake chat-file and delivery adapters:
    • A Cyrillic CSV became a bar chart with Cyrillic labels.
    • The follow-up call reused the session file.
    • A NameError came back as python_error with the traceback.
    • A 400 MB allocation came back as python_killed.

Risk

  • The sandbox container is privileged. nsjail needs namespace, mount and cgroup privileges. Scripts run unprivileged in their own namespaces with the limits above, and the container reaches only the internal network.
  • Memory per call is set at 256 MiB from local measurements: pandas + matplotlib ≈ 80 MiB, statsmodels ≈ 128 MiB.

Deployment plan

  1. The deploy workflow builds both release binaries, then pushes the app image and the new openplotva-sandbox image. The first sandbox build compiles nsjail and installs the package set; later builds reuse the mode=max cache.
  2. deploy-production.sh pulls both images and starts the database services as before. It then starts sandbox, waits for its health check, and runs a smoke script (print(6 * 7) must return 42).
  3. If the smoke script fails, the deploy stops before the app is recreated, and the running app stays on its previous image.
  4. After the app is recreated, health and readiness are checked as before; readiness reports python_sandbox.
  5. After the deploy:
    • check that the sandbox image matches the merged commit;
    • run a real run_python call through the runtime API virtual dialog (SAFE mode runs the script without sending files);
    • re-measure per-library memory on the amd64 host;
    • check the logs of both containers.

Rollback: set SANDBOX_URL= in the production env and recreate the app. The tool then disappears from the model's tool list, and the sandbox container can be stopped.

Review notes

  • Workflow and compose: actionlint .github/workflows/deploy-production.yml passes. The workflow and compose files parse as YAML. docker compose config renders the sandbox service, the internal sandbox-net and SANDBOX_URL. shellcheck deploy/production/deploy-production.sh is clean. tools/maintenance/tests passes (203 tests).
  • Prompt: the change adds one plain sentence inside <agent_behavior> of prompts/aifarm/system.prompt. Partials, role markers and template variables are unchanged. As with any prompt edit, the cached prefix is rebuilt once.
  • Size: about 1.9k of the changed lines are the generated sandbox/requirements.txt and Cargo.lock. The rest is one feature (supervisor, tool, deploy) that only works as a whole.

The model can now run a Python script in a disposable sandbox, copy chat
files into it, and send the files the script creates back to the chat.

- New openplotva-sandbox crate: a supervisor binary that runs each call
  under nsjail (no network, uid 65534, 256 MiB, 1 CPU, 30 s, 16 pids,
  syscall deny list) with a per-session 100 MiB tmpfs home, plus the
  HTTP client the bot uses. Sessions are keyed by chat and thread, close
  60 s after the last call or 5 min after opening, at most 4 at once.
- sandbox/: image with nsjail built from source, CPython 3.14 and a
  pinned data, plotting, office and media package set installed at build
  time, Debian's standard ffmpeg, and the nsjail policy.
- run_python tool: resolves file_ids seen in the same chat, uploads them,
  and after a clean exit sends send_files as photos, albums, animations,
  videos, audio or documents, recording the messages in history and the
  file metadata store. A rejected photo is resent as a document.
- Stored run_python tool results render the script, so a follow-up turn
  edits the earlier code. The tool is offered only when SANDBOX_URL is
  set; SAFE virtual dialogs run scripts without delivering files.
- Telegram builders for sendDocument, sendVideo and sendAnimation uploads.
- Production compose runs the sandbox privileged on an internal network;
  the deploy workflow builds and pushes its image and the deploy script
  starts it and waits for health before recreating the app.
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Warnings
⚠️ Workflow changes should be verified with actionlint and a YAML parser before merge.
⚠️ Production deploy workflow/assets changed; include an explicit deployment-risk note and verification plan.
⚠️ Prompt files are cache-sensitive contracts; confirm prompt shape and role markers were preserved intentionally.
⚠️ Large PR (4865 changed lines). Consider splitting unless this is generated/mechanical.
Messages
📖 Danger checked 42 changed file(s).

Generated by 🚫 dangerJS against 5d114bc

Health covers only the supervisor's HTTP listener; a host that cannot run
nsjail would pass it and leave run_python failing on every call.
The drawing rule sends every "draw" request to draw_image, which would
take a bar chart or a floor plan as an illustration prompt.
Comment thread sandbox/Dockerfile Fixed
@github-actions

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 5 🔵🔵🔵🔵🔵
🔒 No security concerns identified
⚡ Recommended focus areas for review

Partial album send misreported

In send_as_documents, each photo document send uses ?, so the first failure aborts the whole retry: files already delivered in that album are never recorded, and the caller then reports every file of the delivery (including the ones actually sent) as send_errors. The model receives a delivery report claiming photos were not sent when some messages already reached the chat, which can trigger a redundant re-send on retry. A concrete scenario: a 3-photo album where Telegram rejects the photo send and the first document resend fails on a network error — photo 1 may already be sent yet appears in send_errors.

    async fn send_as_documents(
        &self,
        target: &UploadTarget,
        delivery: &Delivery,
        caption: &str,
    ) -> Result<Vec<carapax::types::Message>, String> {
        let mut messages = Vec::new();
        for (index, file) in delivery.files().into_iter().enumerate() {
            let caption = if index == 0 { caption } else { "" };
            messages.extend(
                self.send(target, &Delivery::Document(file.clone()), caption)
                    .await?,
            );
        }
        Ok(messages)
    }
}

- When Telegram rejects a photo group, each file is retried as a
  document on its own, and the report lists exactly the paths that
  reached the chat; the caption goes on the first message that gets
  through.
- The deploy integration test passes OPENPLOTVA_SANDBOX_IMAGE, which the
  deploy script now requires.
- Annotate the sandbox entrypoint: the supervisor needs root for tmpfs
  mounts and cgroups, and scripts run as uid 65534 inside nsjail.
@iamwavecut

Copy link
Copy Markdown
Owner Author

PR-Agent finding "Partial album send misreported": fixed in 8a7924c. A rejected photo group is now retried file by file as documents, the delivery report lists exactly the paths that reached the chat, and the caption goes on the first message that gets through (test rejected_albums_fall_back_per_file_and_report_partial_delivery).

Code scanning ignores in-source Semgrep suppressions, so the
missing-user-entrypoint alert stayed open. USER root states the
intended identity; scripts still run as uid 65534 inside nsjail.
Comment thread sandbox/Dockerfile Dismissed
@iamwavecut

Copy link
Copy Markdown
Owner Author

Danger warnings, handled in the PR description ("Deployment plan" and "Review notes"):

  • Workflow: actionlint .github/workflows/deploy-production.yml passes; the workflow and compose files parse as YAML; docker compose config renders the new service.
  • Deploy risk: the sandbox starts and must pass a smoke run before the app is recreated; rollback is SANDBOX_URL= plus an app recreate.
  • Prompt: one intentional sentence inside <agent_behavior>; partials, role markers and template variables are unchanged.
  • Size: about 1.9k changed lines are the generated sandbox/requirements.txt and Cargo.lock; the rest is one feature that only works as a whole.

@iamwavecut
iamwavecut merged commit 039c8d0 into main Oct 20, 2026
12 checks passed
@iamwavecut
iamwavecut deleted the feat/python-sandbox branch October 20, 2026 16:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants