Skip to content

NodusRuntime applies no max_frames cap despite documenting MAX_STACK_DEPTH — runaway recursion hangs the host #350

Closed

Description

@Masterplanner25

Summary

NodusRuntime applies no call-stack-depth limit, contradicting its own
documented contract. A script with unbounded recursion does not raise
Call stack overflow — it grows the frame stack until the host process becomes
unresponsive.

The CLI is unaffected: nodus run caps at MAX_STACK_DEPTH (10,000) and raises
a sandbox error correctly. This is a CLI-vs-embedded divergence of exactly the
kind CLAUDE.md's security-boundary rule exists to catch.

The broken contract

src/nodus/runtime/embedding.py:310:

max_frames:
    Maximum call stack depth.  Raises a sandbox error on overflow.  ``None``
    means the VM default (``MAX_STACK_DEPTH``).

MAX_STACK_DEPTH appears nowhere else in embedding.py — that docstring is
its only mention in the file. The actual VM default is None
(src/nodus/vm/vm.py:211), which means no cap. Only
src/nodus/tooling/sandbox.py:37 applies it, and that is the CLI path.

Reproduction

from nodus import NodusRuntime

rt = NodusRuntime()
rt.run_source("print(1)")
print(rt._last_vm.max_frames)      # -> None   (docstring promises 10000)

Runaway recursion, using the configuration CLAUDE.md recommends for servers
and long-lived hosts:

rt = NodusRuntime(timeout_ms=None, max_steps=None)
rt.run_source("fn recurse(n) { return recurse(n + 1i) }\nprint(recurse(0i))")
# does not return; no Call stack overflow; process becomes unresponsive

Same program under the CLI behaves correctly:

$ nodus run --time-limit 10000 deep.nd
Sandbox error at .../deep.nd:1:36: Call stack overflow

Passing an explicit bound works, which confirms the mechanism rather than the
plumbing:

rt = NodusRuntime(timeout_ms=None, max_steps=None, max_frames=200)
# -> errors[0]["message"] == "Call stack overflow"

Why this matters

The embedding API is the surface for running untrusted scripts — the
docstring immediately above max_frames discusses exactly that, recommending
allow_env=False for untrusted input. A caller who reads this docstring
reasonably concludes stack depth is bounded by default. It is not, so a hostile
or merely buggy script hangs the host.

timeout_ms does not save you here either: the recommended long-lived-host
configuration is NodusRuntime(timeout_ms=None, max_steps=None) (EMBED-001,
#97), which removes the other two guards by design. That combination leaves no
guard at all.

Fix direction

Either honor the documented contract — default max_frames to
MAX_STACK_DEPTH when None is passed, matching sandbox.py — or correct the
docstring to state plainly that there is no default cap and that embedders must
set max_frames themselves. The first is the safer default and matches what
the CLI already does.

Per the security-boundary rule in CLAUDE.md, the fix needs tests in both
CLI and embedded mode; the CLI side already behaves correctly, so an
embedded-mode regression test is the missing piece.

Related

Affected versions

v4.1.1 (current). The None VM default predates v4.

Activity

  1. Masterplanner25 commented on Aug 7, 2026

    @Masterplanner25
    OwnerAuthor

    Found the exact mechanism while verifying docs/security/SECURITY_MATRIX.md. My
    original report said MAX_STACK_DEPTH "appears nowhere else in embedding.py",
    which is true but understates it — the cap is applied and then thrown away.

    src/nodus/runtime/embedding.py:771:

    configure_vm_limits(vm, max_steps=resolved_steps, timeout_ms=resolved_timeout)
    resolved_frames = self.max_frames if max_frames is None else max_frames
    vm.max_frames = resolved_frames

    configure_vm_limits ends with vm.max_frames = MAX_STACK_DEPTH
    (src/nodus/tooling/sandbox.py:37), so the guard is correctly installed — and
    the very next two lines overwrite it with self.max_frames, which is None
    unless the caller passed one.

    So this is not a missing wire-up. It is a default-handling bug in three lines:
    the assignment is unconditional when it should only apply an explicit override.

    Suggested fix:

    configure_vm_limits(vm, max_steps=resolved_steps, timeout_ms=resolved_timeout)
    resolved_frames = self.max_frames if max_frames is None else max_frames
    if resolved_frames is not None:
        vm.max_frames = resolved_frames

    That honors the documented contract ("None means the VM default
    (MAX_STACK_DEPTH)") without changing behavior for anyone who passes an
    explicit value, and leaves configure_vm_limits as the single place the default
    lives — so CLI and embedded cannot drift again.

    A caller who genuinely wants no cap would then need an explicit sentinel; worth
    deciding whether that case should exist at all, given the API is the one used
    for untrusted scripts.

    Doc impact

    docs/security/SECURITY_MATRIX.md claimed "Stack overflow raises sandbox err —
    ✅ CLI ✅ EMB ✅ SRV". The EMB column was wrong, and the adjacent row called the
    depth limit "❌ hardcoded" in all three contexts when max_frames is in fact a
    constructor parameter. Both rows corrected, pointing here.

  2. Masterplanner25 commented on Aug 14, 2026

    @Masterplanner25
    OwnerAuthor

    Two additions from the docs/runtime/ sweep.

    1. Root cause of the propagation: the docstring on the parameter itself.

    src/nodus/runtime/embedding.py documented the false behavior at the source, which is
    why five documents inherited it:

    max_frames:
        Maximum call stack depth.  Raises a sandbox error on overflow.  ``None``
        means the VM default (``MAX_STACK_DEPTH``).

    MAX_STACK_DEPTH appears in exactly one enforcement site in the whole tree —
    tooling/sandbox.py:37, the CLI path. embedding.py never applies it. Every doc that
    repeated the claim was faithfully copying the docstring. Fixed there as well as in the
    docs, or the next sweep re-derives it.

    Instances found and corrected: SECURITY_MATRIX.md, SECURITY_POSTURE.md,
    EMBEDDING.md (×2), OPERATOR_OR_EMBEDDER_RUNBOOK.md, and the docstring.

    2. The severity is higher than "no cap" in one specific configuration.

    With the default max_steps, runaway recursion still stops — on the step limit:

    {'kind': 'sandbox', 'message': 'Execution step limit exceeded'}
    

    But EMBEDDING.md §2 recommends max_steps=None, timeout_ms=None for long-lived
    hosts — servers, MCP/A2A, workflow engines. In that configuration there is no step
    limit, no deadline, and no frame cap:

    rt = NodusRuntime(max_steps=None, timeout_ms=None)   # the documented server config
    rt.run_source('fn f(n) { return f(n + 1i) }\nf(0i)\n')

    Killed at 60s, still running. No RecursionError — VM frames are heap-allocated, so
    Python's stack is never touched and its recursion limit never fires. The frame list
    grows until the process is OOM-killed.

    So the documented configuration for exactly the long-lived hosts that most need a guard
    is the one configuration with no guard at all. Both docs now carry an explicit warning
    and pass max_frames in the recommended snippet.

    FAILURE_AND_DEGRADATION_MODEL.md also claimed this path produced a Python
    RecursionError "caught as internal error" — it does not, and that row is corrected to
    the two real outcomes.

  3. Masterplanner25 commented on Aug 15, 2026

    @Masterplanner25
    OwnerAuthor

    Fixed in #373 (8886b9e) — your three-line diagnosis in the first comment was exactly right.

    resolved_frames = self.max_frames if max_frames is None else max_frames
    if resolved_frames is not None:
        vm.max_frames = resolved_frames

    None now means MAX_STACK_DEPTH (10,000), matching the docstring and the CLI. The recommended long-lived-host config raises Call stack overflow in 0.2s instead of growing until OOM.

    All three contexts now take the cap from configure_vm_limits. I checked the SRV column while I was in there: services/server.py imports run_source from tooling/runner.py, which calls configure_vm_limits and never touches max_frames — so the server was correct all along, and the matrix's ✅ was accurate.

    On the open question — should "no cap" be expressible? I decided no. max_frames is the only guard still standing when max_steps and timeout_ms are both None, and this is the API used for untrusted scripts, so a sentinel meaning "disable the last guard" is a footgun with no clear use. A host that genuinely wants an unbounded stack passes a large integer; there is a test proving 20,001 frames deep works when asked for. Easy to revisit if a real need turns up.

    Behavior change worth recording: an embedded run recursing deeper than 10,000 frames previously succeeded and now raises. That is the documented contract being honored rather than a new restriction, but it is visible to anyone who was relying on the gap. Called out in the CHANGELOG.

    Tests — tests/test_max_frames_default.py, 9 tests across embedded and CLI per the security-boundary rule; the 4 asserting the embedded default fail against the unfixed code, verified by stashing the fix. The recursion cases run in subprocesses with timeouts on purpose: an uncapped run doesn't raise, it grows, so an in-process test would hang CI rather than fail it. That also closes TG-007 in TEST_GAP_BACKLOG.md — the untested invariant is what hid this.

    Docs swept back. The five documents that had been corrected on 2026-08-07 to describe the broken behavior now describe the fixed behavior, each keeping a note that v4.1.1 and earlier are affected. EXECUTION_INVARIANTS.md I-SAND-03 is unconditional again rather than "holds only when max_frames is passed".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions