Limits

Every session runs under a budget. Limits are charged during evaluation rather than checked afterwards, so a runaway script is stopped rather than reported once it has already cost you the memory.

The execution budget

ExecutionLimits is a record; take a profile and override what you need.

Limit Default What it caps
Timeout 30s wall clock for the whole execution
ParserTimeout 5s wall clock for parsing alone
MaxCommands 10,000 commands executed
MaxLoopIterations 10,000 iterations of any single loop
MaxTotalLoopIterations 1,000,000 loop iterations across the script
MaxFunctionDepth 100 shell function recursion
MaxNestingDepth 100 nesting of compound commands and expansions
MaxInputBytes 10 MB script size
MaxOutputBytes 10 MB bytes retained on stdout or stderr
MaxWorkUnits 100,000,000 abstract work shared by parser, interpreter and builtins
MaxParserFuel 10,000,000 tokens and productions the parser may consume
MaxBraceExpansionItems 10,000 expansions from one brace expansion
MaxGlobMatches 10,000 paths one glob may match

ExecutionLimits.Default mirrors upstream's defaults; ExecutionLimits.Strict tightens them for genuinely untrusted input.

var limits = ExecutionLimits.Default with
{
    Timeout        = TimeSpan.FromSeconds(120),
    MaxCommands    = 100_000,
    MaxOutputBytes = 4_000_000,
};

var bash = Bash.CreateBuilder().WithLimits(limits).Build();

That particular profile is roughly what Curiosity Workspace gives Sudo: a legitimate command there can walk a whole configuration tree, so it gets more room than the default profile for untrusted input, and the wall clock is what actually bounds a runaway loop.

The Python budget

PythonOptions carries the interpreter's own caps:

Option Default What it caps
MaxInstructions 20,000,000 bytecode instructions per invocation
MaxRecursionDepth 200 Python call depth
MaxDirectoryDepth 64 how far a tree walk descends

MaxDirectoryDepth defaults to the same value the shell's filesystem will let a script create, so over that filesystem it can never be reached and costs nothing. Raise the two together, or a walk will refuse to enter a directory the shell was allowed to make.

Filesystem quotas

Separate from the execution budget, and covered in The virtual filesystem: total bytes, file count and path depth.

Truncation and failure

Two different things happen when a budget runs out, and the difference is deliberate.

Output is truncated, and says so. ExecResult carries StdoutTruncated and StderrTruncated, so a caller can tell "this is the whole answer" from "this is the first 4 MB of it".

Everything else raises. A wall clock that expires, a command count that is exhausted, a filesystem quota that is full: each ends the execution rather than returning what it managed to do. A traversal that quietly stopped part-way would report a subset as though it were the whole, and a caller acting on that is worse off than one that got an error.

Never widen a limit to make something pass

A limit that is in the way is telling you the script is doing more work than you budgeted for. Raise it because you decided the budget was wrong, not because a case failed.

Cancellation

Every entry point takes a CancellationToken, threaded explicitly rather than captured. That is the right lever for a request-scoped host: the limits bound a script that misbehaves, and the token cancels one whose caller went away.

var result = await bash.ExecAsync(script, httpContext.RequestAborted);
© 2026 Curiosity. All rights reserved.