Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1776,8 +1776,12 @@ ilo help ai -- compact AI spec to stdout (= contents of ai.
ilo serv -- long-lived JSON request/response loop
ilo --max-ast-depth N <sub> -- cap parser nesting at N (default 256; protects `ilo serv`
and other untrusted-source paths from DoS payloads, raises ILO-P103)
ilo --max-runtime SECS <sub> -- cap wall-clock runtime at SECS (default 60; 0 disables; raises ILO-R016)
ilo --max-output-bytes BYTES <sub> -- cap stdout output at BYTES (default ~100 MB; 0 disables; raises ILO-R017)
```

**Production-safety guards (`ILO-R016`, `ILO-R017`).** `ilo run` caps wall-clock runtime at 60 s and stdout output at ~100 MB by default. A runaway loop (missing increment, recursion with no base case) aborts with `ILO-R016` once the time budget hits, instead of burning CPU forever; a `prnt` loop without termination aborts with `ILO-R017` once the byte budget hits, instead of filling the agent transcript with megabytes of garbage. Both guards write a structured diagnostic to stderr and exit 1. Defaults are well above any legitimate program (real agent tasks finish under 10 s and produce kilobytes); raise with `--max-runtime SECS` / `--max-output-bytes BYTES`, set either to `0` to disable. The guards installed by the mandelbrot persona report (2026-05-20) which spun in an infinite loop and wrote 165 MB of stdout before the harness intervened.

**Verb-noun aliases.** `ilo run <file>` is an exact alias for the bare positional `ilo <file>` - same dispatch, same engine selection, same arg handling. `ilo build <file> -o <out>` is an alias for `ilo compile <file> -o <out>`. Both exist to match the toolchain conventions used by `cargo`, `go`, and `zero` so agents and humans can guess the command name without consulting the help text. The bare positional forms remain fully supported for backwards compatibility; nothing has been removed.

**`ilo check`.** Standalone verifier invocation: lex, parse, resolve imports, and run the type verifier without proceeding to bytecode compilation or execution. Exit code 0 means the program is well-typed and verifier-clean; exit code 1 means at least one diagnostic was emitted on stderr. The output mode follows the global flags (`--json` for NDJSON diagnostics, `--text` for plain text, `--ansi` for coloured output; auto-detected when omitted - JSON when stderr is not a TTY, ANSI otherwise). `ilo check` works on both files and inline code; on a syntactically-broken input it still reports the parse error rather than crashing, which is important for editor and agent loops that may feed in half-written programs.
Expand Down
2 changes: 1 addition & 1 deletion ai.txt

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions examples/runtime-guard.ilo
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- runtime-guard: `ilo run` caps wall-clock at 60 s (default) and stdout at
-- ~100 MB (default). Override with `--max-runtime SECS` and
-- `--max-output-bytes BYTES`. Set either to 0 to disable.
--
-- Hitting the runtime cap aborts with ILO-R016; hitting the output cap
-- aborts with ILO-R017. Both are pure safety nets — a well-behaved program
-- never sees them. The mandelbrot persona run that surfaced this guard
-- missed a loop increment and produced 165 MB of stdout in an infinite
-- loop before the harness killed it; the cap turns that into a structured
-- error the agent can learn from instead.

triangle n:n>n;s=0;i=0;wh <i n{s=+s i;i=+i 1};s

main>n;triangle 10

-- run: main
-- out: 45
4 changes: 4 additions & 0 deletions skills/ilo/ilo-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ AOT-compiled binaries (`ilo compile`) follow the same contract byte-for-byte.

Parser nesting is capped at 256 by default — guards `ilo serv` and any other context that compiles untrusted source against `((((...((1+1))))...))` DoS payloads that would otherwise blow the parser stack. Hand-written ilo rarely exceeds depth 10. Override with `--max-ast-depth N` on `ilo`, `ilo run`, `ilo check`, `ilo build`, or `ilo serv` when a real program needs more. Hitting the cap surfaces as `ILO-P103`.

## Runtime + output caps

`ilo run` caps wall-clock runtime at 60 s and stdout output at ~100 MB by default. A runaway loop aborts with `ILO-R016` (time) or `ILO-R017` (output) instead of spinning forever or filling the transcript with megabytes of garbage. Override with `--max-runtime SECS` and `--max-output-bytes BYTES`; set either to `0` to disable. If you hit either code, check loop variables increment and recursion has a base case — that's the cause 95% of the time.

## Branching

Failures / repair: `ilo-edit-loop`. Runnable patterns: `ilo-examples`. Tools: `ilo-tools`. Engine pick: `ilo-engines`.
2 changes: 2 additions & 0 deletions skills/ilo/ilo-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ description: Use this when reading ILO-XXXX error codes or fixing failures. List
- **R001 div-by-zero** - guard with `=b 0 ^"..."`.
- **R004 wrong main arity** - CLI args don't match signature.
- **R012 no functions defined** - typo'd flag swallowed as positional.
- **R016 wall-clock runtime exceeded** - `ilo run` killed the program at the 60 s (default) budget. Almost always an infinite loop - check loop variables increment and recursion has a base case. Raise with `--max-runtime SECS` if legitimate.
- **R017 stdout output exceeded** - `ilo run` killed the program at the ~100 MB (default) stdout budget. Usually a `prnt` call inside an unbounded loop. Raise with `--max-output-bytes BYTES` if legitimate.
- **R020 file not found** - check path or `env "HOME"`.
- **R030 http error** - non-2xx or network. Match `^e`.
- **R040 json parse error** - bad `jpar` input. Match `^e`.
Expand Down
22 changes: 22 additions & 0 deletions src/cli/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@ pub struct Global {
/// program needs deeper nesting.
#[arg(long = "max-ast-depth", global = true)]
pub max_ast_depth: Option<usize>,

/// Wall-clock budget for `ilo run` in seconds. Default 60. Set to 0 to
/// disable. A runaway loop (missing increment, recursion with no base
/// case) aborts with `ILO-R016` once the budget is hit instead of
/// burning CPU and producing megabytes of useless stdout.
#[arg(long = "max-runtime", global = true)]
pub max_runtime: Option<u64>,

/// Maximum stdout bytes for `ilo run`. Default ~100 MB. Set to 0 to
/// disable. A loop calling `prnt` without termination aborts with
/// `ILO-R017` once the budget is hit, instead of filling the agent
/// transcript with garbage.
#[arg(long = "max-output-bytes", global = true)]
pub max_output_bytes: Option<u64>,
}

#[derive(Subcommand, Debug)]
Expand Down Expand Up @@ -905,6 +919,8 @@ mod tests {
json: false,
no_hints: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
// In test environment stderr is typically not a TTY → should return Json.
// We can't reliably test the TTY branch, but we can test that explicit_json
Expand All @@ -928,6 +944,8 @@ mod tests {
json: true,
no_hints: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
assert!(g.explicit_json());
assert_eq!(g.output_mode(), OutputMode::Json);
Expand All @@ -941,6 +959,8 @@ mod tests {
json: false,
no_hints: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
assert!(!g.explicit_json());
assert_eq!(g.output_mode(), OutputMode::Text);
Expand All @@ -954,6 +974,8 @@ mod tests {
json: false,
no_hints: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
assert!(!g.explicit_json());
assert_eq!(g.output_mode(), OutputMode::Ansi);
Expand Down
53 changes: 53 additions & 0 deletions src/diagnostic/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1444,6 +1444,59 @@ A hard fault from an AOT binary is always a bug in ilo itself —
either a codegen issue in the Cranelift AOT backend, or a missing
runtime check that the other engines apply. Please file an issue
with the source program and the JSON diagnostic.
"#,
},
ErrorEntry {
code: "ILO-R016",
short: "wall-clock runtime budget exceeded",
long: r#"## ILO-R016: wall-clock runtime budget exceeded

`ilo run` aborted because the program ran for longer than the
configured wall-clock budget (default 60 s). The watchdog thread
fires this when `elapsed > --max-runtime SECS`, writes a structured
diagnostic to stderr, and exits with code 1.

By far the most common cause is an infinite loop: a `wh` body that
doesn't update its loop variable, or a recursion with no base case.
The mandelbrot persona run that surfaced this guard missed a
`col=col+1` increment and would have spun forever - the cap turns
that into a clear signal the agent can act on.

Override with `--max-runtime N` (seconds; 0 disables) when a
legitimate program needs longer. Long-running batch jobs and
training loops are the normal reason to bump or disable it.

```
ilo --max-runtime 300 main.ilo -- allow 5 minutes
ilo --max-runtime 0 main.ilo -- disable the cap
```
"#,
},
ErrorEntry {
code: "ILO-R017",
short: "stdout output budget exceeded",
long: r#"## ILO-R017: stdout output budget exceeded

`ilo run` aborted because the program wrote more bytes to stdout
than the configured budget (default ~100 MB). Every `prnt` call in
every engine (tree, VM, Cranelift JIT) charges its output against
the budget; when the total exceeds `--max-output-bytes`, the next
write triggers a structured diagnostic to stderr and exits 1.

The most common cause is a loop calling `prnt` without termination
or without backing off: an unbounded `wh` body, a recursion with
no base case, or a missing increment on the loop variable. The
budget keeps a runaway from filling disk or the agent transcript
with megabytes of useless output before anyone notices.

Override with `--max-output-bytes N` (bytes; 0 disables) when a
legitimate program produces a lot of output - typically structured
data dumps, log replay, or a code-generation pipeline.

```
ilo --max-output-bytes 1073741824 main.ilo -- raise to 1 GB
ilo --max-output-bytes 0 main.ilo -- disable the cap
```
"#,
},
ErrorEntry {
Expand Down
6 changes: 5 additions & 1 deletion src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4700,7 +4700,11 @@ fn call_function(env: &mut Env, name: &str, args: Vec<Value>) -> Result<Value> {
.into_iter()
.next()
.expect("prnt: arity=1 guaranteed by caller");
println!("{v}");
let s = format!("{v}");
// +1 for the trailing newline `println!` adds. Charging it keeps the
// byte budget honest against a `wh true{prnt 0}` runaway.
crate::runtime_guard::record_output(s.len() + 1);
println!("{s}");
return Ok(v);
}
if builtin == Some(Builtin::Jdmp) && args.len() == 1 {
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub mod graph;
pub mod interpreter;
pub mod lexer;
pub mod parser;
pub mod runtime_guard;
pub mod tools;
pub mod verify;
pub mod vm;
Loading
Loading