Skip to content

feat(code-mode): isolate snippet execution in a subprocess with RPC bridge - #10

Merged
klauern merged 4 commits into
mainfrom
nklauer/code-mode-subprocess-isolation
Jun 10, 2026
Merged

feat(code-mode): isolate snippet execution in a subprocess with RPC bridge#10
klauern merged 4 commits into
mainfrom
nklauer/code-mode-subprocess-isolation

Conversation

@klauern

@klauern klauern commented Jun 9, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the long-standing Code Mode runner gap: user snippets ran in the server process under a soft asyncio.wait_for timeout that could not interrupt synchronous blocking or CPU-bound code. They now run in a fresh child process per call with a hard, kill-enforced timeout.

Closes mcp-ynab-a43 (subprocess isolation + RPC bridge) and mcp-ynab-fkv (soft-timeout bug). Remaining OS-level hardening (RLIMIT/seccomp/env-scrub/network) split into the new mcp-ynab-fsv.7.

How it works

  • Parent (runner.py): audits the snippet (AST allow-list) before spawning, holds the live MCP tool registry and request ctx (neither crosses the process boundary), spawns the worker, and answers ynab.read/ynab.write calls over a stdio JSON-RPC bridge. Hard-kill()s the child on timeout; fails closed on malformed frames.
  • _worker.py: side-effect-free child, launched by file path (not python -m) so it never triggers mcp_ynab/__init__'s from .server import mcp — the child boots without building the FastMCP app or needing a YNAB API key. Builds RPC stubs, runs the snippet, captures stdout, ships a result frame.
  • _sandbox.py: new leaf module of pure primitives (safe builtins, code wrap, bounded stdout, result truncation, length-prefixed framing) shared by both sides. Imports only stdlib + pydantic_core.

Length-prefixed frames (<len>\n<payload> + readexactly) are used instead of newline-delimited JSON so a single tool result can exceed asyncio's 64KB line-buffer limit.

Tests (314 pass, +3 new)

  • test_run_code_hard_kills_synchronous_blocking_code — a non-cooperative while True loop is killed at the 0.2s timeout (~0.21s wall), the fkv guard.
  • test_run_code_handles_large_rpc_payload — a ~200KB tool result round-trips without deadlock (framing guard).
  • test_execute_end_to_end_spawns_real_subprocess — real server.execute → real mcp registry → real worker in the live event loop, no mocks.

All 39 pre-existing Code Mode tests pass unchanged (behavior/contract preserved: CodeModeResult schema, stdout→logs capture, Pydantic boundary serialization, mutations gating, truncation byte counts).

Out of scope (tracked in mcp-ynab-fsv.7)

RLIMIT/seccomp, child env scrubbing (currently inherits parent env), network isolation. The process boundary contains crashes/hangs/accidental blocking; it is not yet a hardened sandbox for adversarial Python.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Code Mode now runs snippets in isolated subprocesses with bounded output capture and explicit read/write permission handling.
  • Bug Fixes

    • Timeouts are enforced by terminating hung workers so blocking/CPU-bound snippets no longer stall the server.
  • Documentation

    • Runner docs updated to explain subprocess execution, auditing, and safety guidance.
  • Tests

    • Added tests for large-output handling and timeout/kill behavior.
  • Chores

    • Sync remote target configured and CI workflow permissions adjusted.

klauern added 2 commits June 8, 2026 20:59
…ridge

User snippets now run in a fresh child process (`_worker.py`) per call instead
of in the server process. The parent audits the snippet (AST allow-list) before
spawning, holds the live MCP tool registry and request `ctx` (neither crosses
the process boundary), and answers `ynab.read`/`ynab.write` calls over a stdio
JSON-RPC bridge using length-prefixed frames.

Because user code runs in a separate OS process, the execution timeout is now a
hard wall clock: on expiry the parent `kill()`s the child, terminating
synchronous blocking or CPU-bound code that the old in-process
`asyncio.wait_for` could never interrupt. Closes mcp-ynab-fkv.

- New `_sandbox.py`: leaf module of pure primitives (safe builtins, code wrap,
  bounded stdout, result truncation, length-prefixed framing) shared by parent
  and worker. Imports only stdlib + pydantic_core — never the server — so the
  worker boots without building the FastMCP app or demanding an API key.
- New `_worker.py`: side-effect-free child, launched by file path (not `-m`) to
  bypass `mcp_ynab/__init__`'s server import. Builds RPC stubs, runs the
  snippet, captures stdout, ships a result frame.
- `runner.py`: rewritten parent side — audit-then-spawn, concurrent RPC serving
  (no deadlock), hard-kill on timeout, fail-closed on malformed frames.
- Tests: hard-kill of a non-cooperative loop (fkv guard), >64KB RPC payload
  round-trip (framing/deadlock guard), and a real `server.execute` -> real mcp
  -> real subprocess end-to-end run.

Out of scope (tracked separately): RLIMIT/seccomp (mcp-ynab-fsv.1b), env
scrubbing, network isolation.

Refs mcp-ynab-a43
@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR moves Code Mode execution to a per-invocation subprocess: parent audits and spawns a worker, communicates over length-prefixed JSON frames for RPC, and enforces hard timeouts by killing the child; the worker runs user code with safe builtins, bounded stdout, and result truncation.

Changes

Subprocess-Isolated Code Mode Execution

Layer / File(s) Summary
Sandbox Protocol and Utilities
src/mcp_ynab/code_mode/_sandbox.py
Length-prefixed JSON framing (encode_frame, read_frame), SAFE_BUILTINS, wrap_code, result serialization/truncation, truncate, and BoundedStringIO for bounded stdout capture.
Worker Process Implementation
src/mcp_ynab/code_mode/_worker.py
Child process that loads sandbox by path, implements _RpcBridge for parent RPC calls, builds ynab proxy (write access conditional), executes wrapped code in sandbox globals with stdout capture, and returns result/error frames.
Runner Subprocess Orchestration
src/mcp_ynab/code_mode/runner.py
Parent audits wrapped code, spawns _worker.py, serves RPC loop (omitting writes when disabled), enforces wall-clock timeout via hard kill, and assembles CodeModeResult; removes prior in-process exec path.
Documentation and Robustness Tests
src/mcp_ynab/code_mode/README.md, tests/test_code_mode.py
README updated for subprocess model and hard-kill semantics. Tests added for large RPC payload round-trip, hard-kill on synchronous blocking code, and end-to-end server.execute integration.

CI and Repo Config

Layer / File(s) Summary
Beads sync remote and workflow permission
.beads/config.yaml, .github/workflows/release-labels.yml
Renames sync-branchsync.branch, adds sync.remote: "git+https://github.com/klauern/mcp-ynab.git", and updates permissions.pull-requests to write in the release-labels workflow.

Sequence Diagram

sequenceDiagram
  participant Parent
  participant Worker
  participant UserCode
  participant Tools
  Parent->>Worker: startup frame (code, mode, dispatch, limits)
  Worker->>Worker: wrap_code + compile
  Worker->>UserCode: exec in sandbox globals
  UserCode->>Worker: ynab.read/ynab.write call (rpc)
  Worker->>Parent: rpc request frame
  Parent->>Tools: call real tool
  Tools-->>Parent: tool result
  Parent->>Worker: rpc response frame
  UserCode->>Worker: return result
  Worker->>Parent: final result frame (value, logs, truncated)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

I'm a rabbit in a sandbox den,
I hop through frames and back again.
A child process hums, the parent keeps the key,
JSON whispers over pipes, safe and free.
🐰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main change: moving snippet execution from in-process to a subprocess with RPC-based communication and hard timeouts.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch nklauer/code-mode-subprocess-isolation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
.beads/config.yaml (1)

64-64: Beads config key formatting is normalized; sync.remote should be applied correctly.

sync.remote at .beads/config.yaml line 64 matches Beads’ canonical dotted key format. Beads also normalizes known hyphenated aliases, so the existing sync-branch at line 45 should not be ignored at runtime (it should map to sync.branch).

  • Optional: rename sync-branchsync.branch in the config for consistency.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.beads/config.yaml at line 64, sync.remote is already using the canonical
dotted key but the hyphenated alias sync-branch may be confusing; update the
config so the branch key uses the canonical form by renaming sync-branch →
sync.branch and verify the existing sync.remote entry remains unchanged; ensure
any code reading the config (references to sync.remote, sync.branch) still works
with Beads' normalization if you choose to keep the alias, but prefer the
explicit sync.branch key for consistency.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In @.beads/config.yaml:
- Line 64: sync.remote is already using the canonical dotted key but the
hyphenated alias sync-branch may be confusing; update the config so the branch
key uses the canonical form by renaming sync-branch → sync.branch and verify the
existing sync.remote entry remains unchanged; ensure any code reading the config
(references to sync.remote, sync.branch) still works with Beads' normalization
if you choose to keep the alias, but prefer the explicit sync.branch key for
consistency.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b3b44cc1-78f0-47a4-ac2b-84a117818a62

📥 Commits

Reviewing files that changed from the base of the PR and between 249666a and b52dbcd.

📒 Files selected for processing (6)
  • .beads/config.yaml
  • src/mcp_ynab/code_mode/README.md
  • src/mcp_ynab/code_mode/_sandbox.py
  • src/mcp_ynab/code_mode/_worker.py
  • src/mcp_ynab/code_mode/runner.py
  • tests/test_code_mode.py

Adding/removing labels on a pull request maps to the `pull-requests`
permission scope even though the REST path is `/issues/{n}/labels`. The
workflow declared `issues: write` but only `pull-requests: read`, so
`issues.addLabels` returned 403 "Resource not accessible by integration"
on every PR that needed a release label (observed on PR #10 and the
dependabot PRs).

Grant `pull-requests: write` so the labeler can apply patch/minor/major
labels. Note: pull_request_target runs the base-branch copy of the
workflow, so this takes effect for PRs opened after it lands on main.
@klauern klauern added the minor Release: minor version bump label Jun 9, 2026
@klauern klauern added patch Release: patch version bump and removed minor Release: minor version bump labels Jun 9, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.beads/config.yaml:
- Line 45: Update the Beads config's sync.remote value to use a Dolt-compatible
URL scheme: replace the current git+https://... remote with
https://github.com/klauern/mcp-ynab.git so Beads does not prepend/modify the
scheme; ensure the dotted key sync.remote (and existing sync.branch) remain
unchanged and the URL is exactly the https:// form.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fa4202a1-b183-492a-9ad8-ea905917e053

📥 Commits

Reviewing files that changed from the base of the PR and between aa9a30a and 452d764.

📒 Files selected for processing (1)
  • .beads/config.yaml

Comment thread .beads/config.yaml
# Can also use BEADS_SYNC_BRANCH env var for local override.
# If not set, bd sync will require you to run 'bd config set sync.branch <branch>'.
sync-branch: "beads-sync"
sync.branch: "beads-sync"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

What is the correct configuration key format for Beads sync branch setting: sync-branch or sync.branch?

💡 Result:

The correct configuration key for the Beads sync branch setting is sync.branch [1][2][3]. Official documentation and codebase references consistently use the dot-separated format sync.branch for setting, retrieving, and unsetting this configuration [1][2][3]. For example, you can set the sync branch using the following command [2]: bd config set sync.branch You can verify the current setting with [2][3]: bd config get sync.branch Additionally, the corresponding environment variable for this setting is BEADS_SYNC_BRANCH [1][2].

Citations:


🌐 Web query:

Beads config key sync.remoteexpected URL scheme: should it begit+https://...or plainhttps://...? Also confirm Beads uses sync.remote and what values it accepts.

💡 Result:

Beads does utilize the sync.remote configuration key, which is used to define the remote repository for synchronization [1][2]. Regarding the URL scheme, you should generally provide the URL as it would be used by Dolt (the underlying engine for Beads) without extra prefixes unless required by your specific remote type [3]. Importantly, recent updates to Beads (as of April 2026) specifically avoid normalizing or altering user-provided sync.remote URLs because automatic conversion (such as adding git+ prefixes) was found to break certain Dolt remotesapi endpoints [4]. Therefore, you should provide the exact URL string compatible with your target remote (e.g., https://github.com/org/repo.git) [4][3]. Values accepted by sync.remote are any Dolt-compatible remote URLs [2][5]. Examples of supported schemes include: - HTTPS: https://github.com/org/repo.git [3] - SSH: git+ssh://git@github.com/org/repo.git [5][3] - Other Dolt-supported backends: dolthub://, s3://, gs://, az://, or file:// [1][3] You can configure this setting via the command line (e.g., bd config set sync.remote ) or by directly editing the.beads/config.yaml file [1][2][5]. Note that Beads will persist this value into your configuration to ensure it is used for synchronization [2][5].

Citations:


Fix sync.remote URL scheme in .beads/config.yaml (line 64)

  • Line 45: sync.branch (dotted notation) is the correct Beads config key; the change is fine.
  • Line 64: Beads expects a Dolt-compatible remote URL and avoids adding/rewriting prefixes—use https://github.com/klauern/mcp-ynab.git instead of git+https://... to prevent sync failures.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.beads/config.yaml at line 45, Update the Beads config's sync.remote value
to use a Dolt-compatible URL scheme: replace the current git+https://... remote
with https://github.com/klauern/mcp-ynab.git so Beads does not prepend/modify
the scheme; ensure the dotted key sync.remote (and existing sync.branch) remain
unchanged and the URL is exactly the https:// form.

@klauern
klauern merged commit 31140c2 into main Jun 10, 2026
4 of 5 checks passed
@klauern
klauern deleted the nklauer/code-mode-subprocess-isolation branch June 10, 2026 13:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

patch Release: patch version bump

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant