Repository navigation
NodusRuntime applies no max_frames cap despite documenting MAX_STACK_DEPTH — runaway recursion hangs the host #350
Description
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 unresponsiveSame 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
- BUG-048: stack overflow trace prints all 10,000 frames with no depth cap #49 (reopened) — the overflow trace itself is uncapped on the
nodus run
path. Found alongside this while re-verifyingdocs/guide/debugging.md. - EMBED-001: NodusRuntime() defaults to 200ms deadline — traps every long-lived embedder #97 / EMBED-001 — the
timeout_msdefault trap; same family of
embedded-default surprises.
Affected versions
v4.1.1 (current). The None VM default predates v4.
Activity
Masterplanner25 commentedon Aug 7, 2026
Found the exact mechanism while verifying
docs/security/SECURITY_MATRIX.md. Myoriginal 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_limitsends withvm.max_frames = MAX_STACK_DEPTH(
src/nodus/tooling/sandbox.py:37), so the guard is correctly installed — andthe very next two lines overwrite it with
self.max_frames, which isNoneunless 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:
That honors the documented contract ("
Nonemeans the VM default(
MAX_STACK_DEPTH)") without changing behavior for anyone who passes anexplicit value, and leaves
configure_vm_limitsas the single place the defaultlives — 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.mdclaimed "Stack overflow raisessandboxerr —✅ CLI ✅ EMB ✅ SRV". The EMB column was wrong, and the adjacent row called the
depth limit "❌ hardcoded" in all three contexts when
max_framesis in fact aconstructor parameter. Both rows corrected, pointing here.
docs(policy,security): correct two false enforcement claims in SECURI…
docs(governance): record the sweep's nine findings; fix a repeated fa…
Masterplanner25 commentedon Aug 14, 2026
Two additions from the
docs/runtime/sweep.1. Root cause of the propagation: the docstring on the parameter itself.
src/nodus/runtime/embedding.pydocumented the false behavior at the source, which iswhy five documents inherited it:
MAX_STACK_DEPTHappears in exactly one enforcement site in the whole tree —tooling/sandbox.py:37, the CLI path.embedding.pynever applies it. Every doc thatrepeated 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:But
EMBEDDING.md§2 recommendsmax_steps=None, timeout_ms=Nonefor long-livedhosts — servers, MCP/A2A, workflow engines. In that configuration there is no step
limit, no deadline, and no frame cap:
Killed at 60s, still running. No
RecursionError— VM frames are heap-allocated, soPython'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_framesin the recommended snippet.FAILURE_AND_DEGRADATION_MODEL.mdalso claimed this path produced a PythonRecursionError"caught as internal error" — it does not, and that row is corrected tothe two real outcomes.
docs(runtime): document 2 undocumented opcodes and 9 missing sandbox …
fix(embedding): apply MAX_STACK_DEPTH by default in NodusRuntime (#350)…
Masterplanner25 commentedon Aug 15, 2026
Fixed in #373 (
8886b9e) — your three-line diagnosis in the first comment was exactly right.Nonenow meansMAX_STACK_DEPTH(10,000), matching the docstring and the CLI. The recommended long-lived-host config raisesCall stack overflowin 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.pyimportsrun_sourcefromtooling/runner.py, which callsconfigure_vm_limitsand never touchesmax_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_framesis the only guard still standing whenmax_stepsandtimeout_msare bothNone, 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 inTEST_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.mdI-SAND-03 is unconditional again rather than "holds only whenmax_framesis passed".fix(cli): handle --help centrally so it never runs the command (#353, #…
fix(workflow): a sweeper must not adopt runs it does not own (#376) (#…
release: Gate 10 creator validation for v4.2.0; two findings filed
release: v4.2.0 (#414)
docs(evals): post-publish eval for v4.2.0 -- clean, no findings
docs(evals): post-publish eval for v4.2.0 -- clean, no findings (#417)
fix: a directly constructed VM caps call depth by default (#458)