Skip to content

Resource Guards

Even within a perfectly sandboxed environment where external access is gated behind permissions, untrusted code can still attack the host through resource exhaustion:

  • An infinite loop consumes CPU forever
  • A recursive function overflows the stack
  • A string concatenation in a loop builds a multi-gigabyte object
  • A JSON parse of a malicious payload fills all available memory

Nox employs multiple independent Watchdogs to prevent multiple categories of resource exhaustion.

Infinite loops, excessive computation, denial-of-service via CPU monopolization.

The VM increments a counter every iteration of the main execution loop:

while (running) {
val inst = bytecode[pc++]
if (++instructionCount > MAX_INSTRUCTIONS) {
throw QuotaExceededException(
"Execution limit exceeded: $MAX_INSTRUCTIONS instructions"
)
}
when (opcode) { /* ... */ }
}
ParameterDefaultDescription
maxInstructions500,000Maximum VM instructions per execution
  • Overhead: Minimal. A single long increment and comparison per cycle.
  • Deterministic: Unlike wall-clock timeouts, instruction counting is consistent regardless of host load.
  • Granular: Each execution gets its own counter, reset to zero.

Every VM loop iteration = 1 instruction. This includes:

  • Arithmetic operations (IADD, DMUL, etc.)
  • Data movement (MOV, LDC)
  • Control flow (JMP, JIF, CALL, RET)
  • System calls (SCALL)
  • Host operations (HMOD, HACC, SCONCAT)

This means a potentially blocking call like Http.get() will be counted as an single instruction, however, if it takes too long, it will be terminated by the execution timeout guard.

Each yield counts as an instruction. The loop condition check counts. The jump-back counts. The instruction counter will catch this after MAX_INSTRUCTIONS / ~3 iterations.

Guard 2: Execution Timeout (Wall-Clock Limit)

Section titled “Guard 2: Execution Timeout (Wall-Clock Limit)”

Scripts that are stuck waiting (e.g., on a slow network call), or scripts that circumvent the instruction counter through long-running system calls.

The timeout runs as a separate watchdog coroutine alongside the VM loop, using Kotlin structured concurrency. This ensures the timer fires even during a blocking SCALL (e.g., Http.get() downloading a large file).

coroutineScope {
val vmJob = async { runVM() }
val watchdog = launch {
var timeoutMs = config.maxExecutionTime.toMillis()
while (true) {
delay(timeoutMs)
// Timeout hit so we ask Host for extension via resource protocol
val response = context.requestResourceExtension(
ResourceRequest.ExecutionTimeout(elapsed, timeoutMs)
)
when (response) {
is ResourceResponse.Granted -> timeoutMs = response.newLimit
is ResourceResponse.Denied -> {
vmJob.cancel(CancellationException(response.reason))
return@launch
}
}
}
}
vmJob.await()
watchdog.cancel()
}

A naïve approach checks the clock inside the VM loop (e.g., every 1024 instructions). This fails when:

  • A blocking SCALL stalls the loop on a single instruction
  • The timer check never fires during the stall
  • The resource extension protocol can’t trigger when it matters most

The watchdog coroutine runs on the coroutine dispatcher independently of the VM loop. delay() is non-blocking and fires on schedule regardless of what the VM is doing.

ParameterDefaultDescription
maxExecutionTime60 secondsMaximum wall-clock time per execution
  • Complementary: Catches cases the instruction counter cannot (e.g., a blocking SCALL that takes forever).
  • Non-deterministic: Results may vary based on host system load.
  • Event-driven: No busy-polling or System.nanoTime() in the hot path.

Memory bombs like allocating enormous strings, arrays, or JSON objects that cause OutOfMemoryError on the host JVM.

The VM monitors the size of objects when they enter the system:

  • System call results: When File.read() or Http.get() returns data, the size is checked before it’s placed in rMem.
  • String interpolation: When a template literal produces a large string, the result size is validated.
  • JSON parsing: Results of JSON operations are measured.
// Inside File.read implementation:
val content = readFileFromDisk(path)
if (content.length * 2 > MAX_OBJECT_SIZE) { // *2 for UTF-16 char size
throw MemoryLimitException(
"Object size ${content.length} exceeds limit"
)
}
rMem[bp + destReg] = content
ParameterDefaultDescription
maxObjectSize100 MBMaximum size for any single object entering the VM
maxReferenceSlots65,536Maximum number of reference slots (limits total object count)
SourceMeasurement
File.read() resultstring.length() * 2 (UTF-16 bytes)
Http.get() responseResponse body size
JSON parse resultEstimated NoxObject/NoxArray size
Array growthElement count against max
String interpolationResult string length

Exact memory measurement on the JVM is expensive. We use proxy metrics (string length, element count) rather than attempting to measure deep object graph sizes with Instrumentation.getObjectSize(). This is a deliberate trade-off: the caps are set conservatively to compensate for measurement imprecision.

Stack overflow attacks like deeply recursive functions that exhaust the host JVM’s stack.

The VM maintains an internal call stack as a fixed-size array:

val callStack = IntArray(MAX_CALL_DEPTH * FRAME_SIZE)
var callStackPointer = 0
// In CALL handler:
if (callStackPointer >= callStack.size) {
throw StackOverflowException(
"Maximum recursion depth exceeded: $MAX_CALL_DEPTH"
)
}
ParameterDefaultDescription
maxCallDepth1,024Maximum number of nested function calls

The JVM’s default thread stack size (typically 512KB–1MB) would also catch infinite recursion. But:

  1. A StackOverflowError in the JVM is unrecoverable and can corrupt internal state
  2. The error message would reference JVM internals, not NSL source code
  3. Coroutines rely on heap-allocated continuation frames, but unchecked recursion still risks excessive memory use
  4. We want to throw a Nox exception with proper source mapping, not a JVM error

Scripts that declare an absurd number of variables, overwhelming the pre-allocated register arrays.

The compiler enforces limits during compilation:

LimitValueEnforced At
Max registers per function~32,768Compile time (16-bit operand)
Max total primitive registers~65,536VM startup (array allocation)
Max total reference registers~65,536VM startup (array allocation)

A script that exceeds these limits receives a CompilationError before any code runs.

┌────────────────────────────────────────────┐
│ RESOURCE GUARDS │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Instruction │ │ Wall-Clock │ │
│ │ Counter │ │ Timeout │ │
│ │ (CPU) │ │ (Time) │ │
│ │ 500K ops max │ │ 60s max │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Memory Cap │ │ Recursion │ │
│ │ (RAM) │ │ Limit │ │
│ │ 100MB per obj │ │ 1024 frames │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ Register File │ All limits are │
│ │ (Variables) │ configurable │
│ │ 65K slots │ per-execution │
│ └─────────────────┘ │
└────────────────────────────────────────────┘

All guards are independent. A failure in any single guard triggers program termination regardless of the state of other guards.

Resource limits are mutable at runtime. When a guard trips, instead of immediately throwing, the VM suspends and asks the Host whether to extend the limit. This allows Hosts to implement dynamic policies (e.g., auto-grant up to 5M instructions, then deny).

VM Host
│ │
│── instructionCount > limit ──────────────▶│
│ ResourceRequest.InstructionQuota( │
│ used=500000, currentLimit=500000) │
│ │
│ (VM coroutine suspends) │── Evaluate policy
│ │
│◀── ResourceResponse.Granted(1000000) ─────│
│ maxInstructions = 1000000 │
│ (VM resumes execution) │
│ │
│── instructionCount > 1000000 ────────────▶│
│ ResourceRequest.InstructionQuota(...) │
│◀── ResourceResponse.Denied("Hard limit") ─│
│ throw QuotaExceededError │
interface RuntimeContext {
suspend fun requestResourceExtension(request: ResourceRequest): ResourceResponse
}
sealed class ResourceRequest {
data class InstructionQuota(val used: Long, val currentLimit: Long) : ResourceRequest()
data class ExecutionTimeout(val elapsedMs: Long, val currentLimitMs: Long) : ResourceRequest()
data class CallDepth(val current: Int, val currentLimit: Int) : ResourceRequest()
}
sealed class ResourceResponse {
data class Granted(val newLimit: Long) : ResourceResponse()
data class Denied(val reason: String? = null) : ResourceResponse()
}

QuotaExceededError, TimeoutError, and StackOverflowError are catchable by user code via try/catch (but ignored by catch-all catch (err) handlers to prevent accidental infinite loops).

To ensure the VM doesn’t immediately crash again while trying to execute the catch block, resource quotas are automatically granted a “grace period” upon denial using a capped exponential backoff. For example, if an instruction limit of 200 is denied, the limit is bumped to 400 (+200). For large limits, the increment is capped (e.g., +10000 instructions). This provides enough headroom for the catch block to perform cleanup and execute the compiler-emitted KILL instruction, ensuring the program terminates gracefully.

Resource guard violations produce clear, actionable error messages:

{
"error": {
"type": "QuotaExceededException",
"message": "Execution limit exceeded: 500000 instructions.",
"suggestion": "Your script may contain an infinite loop. Check while/for loop conditions.",
"details": {
"instructionsExecuted": 500000,
"lastPC": 1247,
"lastOpcode": "JMP",
"file": "data_processor.nox",
"approximateLine": 34
}
}
}