Repository navigation
max_memory_mb: bound how much a run grows the process (#160) - #730
Merged
Merged
Conversation
…160) NodusRuntime(max_memory_mb=256) `max_steps` bounds instructions and `timeout_ms` bounds wall clock; neither stopped a script growing a list until the host got a MemoryError or the OS killed the process. SEC-001 is that gap. Off by default like the other two -- how much memory a run may have is host policy. THREE OF THE ISSUE'S FOUR PROPOSED APPROACHES WERE MEASURED AND REJECTED, and the numbers are the reason this reads RSS from the OS instead: tracemalloc 64x SLOWDOWN (0.026s -> 1.681s on a 300k loop) and 11.9 us/read. Unusable even opt-in, and #173 already names throughput as the bootstrapping blocker. sys.getallocatedblocks 0.8 us, but counts BLOCKS NOT BYTES, so it cannot back a limit in megabytes without inventing a conversion. Absent on PyPy. counting in the VM's deterministic, but measures ELEMENTS where an embedder value constructors asked for megabytes. Left on the table, not dismissed. RSS via the OS 4.4 us, real megabytes, Windows and POSIX. Chosen. Polled every 256 instructions -- ~0.6% against Nodus's ~2.75 us per instruction. It started at 2000 and came down because a string doubling in a loop reached a MemoryError BETWEEN two checks. WHAT IT CANNOT DO, said plainly in SECURITY_POSTURE.md rather than left implied: polling bounds growth over time, not a SINGLE allocation. One enormous list is served before the next check. OS-level limits (ulimit -v, cgroups, a container cap) remain the answer for hard memory safety, not something this replaces. It also measures PROCESS RSS, so a host allocating on another thread is charged to the guest. REFUSED AT CONSTRUCTION where the platform cannot be metered, rather than accepted and silently unenforced. A security control an operator believes they have is worse than none -- the pattern #473 and #478 were both filed for. THE PYPY BUDGET CAUGHT THE FIRST VERSION. It added four VM instance attributes and tests/test_vm_attribute_budget.py failed at 81 against the 80-attribute map limit, which cost ~9x throughput the last time it was crossed (#702, #488). The fix was the one CLAUDE.md prescribes -- shed attributes, do not raise the number: the runtime reads the baseline at run start and hands the VM a single absolute CEILING, so four attributes became one, the interval became a class constant, and a modulo replaced the "last checked" counter. The design is better for it. Verified: the issue's own reproduction aborts with "Memory limit exceeded: this run grew the process to 61 MB, past its 60 MB ceiling" (686s, which is #173's throughput and not this check). Removing the check leaves the same program still running at 45s where it stops in 1.7s with it. Accepting an unenforceable limit instead of refusing fails the refusal test. mypy also caught the narrowing loss from extracting the check into a helper -- the same trap CLAUDE.md records from the bytecode-cache refactor -- and the POSIX branch reaches `resource` through getattr so it type-checks on Windows and on the Linux runner alike. nodus_gate --all green across nine phases. ruff and mypy clean. 191 memory/ embedding/sandbox/limit tests pass. Closes #160 Claude-Session: https://claude.ai/code/session_01B4R857intVYD1fLgnN4uVn Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`test_rss_is_readable_and_grows` allocated ~25 MB and asserted the reading rose.
It passed on Windows and failed on CI with:
AssertionError: 179421184 not greater than 180469760
The reading went DOWN. Python satisfied the allocation from pages the process
already held while the surrounding 3192-test run freed others. Nothing was wrong
with the meter or the limit -- every behavioural test passed, including the one
that aborts a growing program. The assumption that a small allocation must move
RSS was wrong.
Split in two: one test that the reading is plausible, and one that allocates
~150 MB of fresh pages and requires at least 50 MB of movement. The margin is
what makes it a statement about the meter rather than about the runner's mood.
The granularity is worth knowing rather than papering over, so it is now recorded
in the test and the changelog: `max_memory_mb` bounds a run's FOOTPRINT, not its
allocation count, and small churn is invisible to it by design.
Claude-Session: https://claude.ai/code/session_01B4R857intVYD1fLgnN4uVn
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #160 (SEC-001).
max_stepsbounds instructions andtimeout_msbounds wall clock; neither stopped a script growing a list until the host got aMemoryError. Off by default like the other two.Three of the issue's four approaches were measured and rejected
The numbers are the reason this reads RSS from the OS rather than counting allocations:
tracemallocsys.getallocatedblocks()Polled every 256 instructions: ~0.6% against Nodus's ~2.75 µs per instruction. It started at 2000 and came down because a string doubling in a loop reached a
MemoryErrorbetween two checks.What it cannot do
Said plainly in
SECURITY_POSTURE.md §5rather than left implied: polling bounds growth over time, not a single allocation. One enormous list is served before the next check fires. OS-level limits (ulimit -v, cgroups, a container cap) remain the answer for hard memory safety — this does not replace them. It also measures process RSS, so a host allocating on another thread is charged to the guest.Refused at construction where the platform cannot be metered, rather than accepted and silently unenforced. A security control an operator believes they have is worse than none — the pattern #473 and #478 were both filed for.
The PyPy budget caught the first version
It added four VM instance attributes, and
tests/test_vm_attribute_budget.pyfailed at 81 against the 80-attribute map limit — the cliff that cost ~9x throughput the last time it was crossed (#702, #488).The fix was the one CLAUDE.md prescribes — shed attributes, don't raise the number. The runtime now reads the baseline at run start and hands the VM a single absolute ceiling, so four attributes became one, the interval became a class constant, and a modulo replaced the "last checked" counter. The design is better for the constraint.
Verification
The issue's own reproduction aborts with:
(686s to get there — that is #173's throughput, not this check.)
mypy also caught the narrowing loss from extracting the check into a helper — the same trap CLAUDE.md records from the bytecode-cache refactor — and the POSIX branch reaches
resourcethroughgetattr, so it type-checks on Windows and on the Linux runner alike.nodus_gate --allgreen across nine phases;ruffandmypyclean; 191 memory/embedding/sandbox/limit tests pass.🤖 Generated with Claude Code
https://claude.ai/code/session_01B4R857intVYD1fLgnN4uVn