Skip to content
Merged
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
5 changes: 5 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ Common shapes reached for from other languages. The parser and lexer surface eac
| `- -*a b *c d` (double-minus) | `- 0 +*a b *c d` (negate the sum) | `ILO-P021` |
| `[k fmt2 v 2]` (call in list) | `[k (fmt2 v 2)]` or bind-first | `ILO-P101` |
| `pts=gen-pts;cs0=[...];prnt cs0` at top level | `main>_;pts=gen-pts;cs0=[...];prnt cs0` (wrap in `main>_;`) | `ILO-P102` |
| `((((...((1+1))))...))` 1000 deep | bind intermediates, or pass `--max-ast-depth N` | `ILO-P103` |

Each case fires a hint pointing at the canonical form; the agent's first retry should be the right one. Identifier-shaped collisions with builtin names (`len=...`, `sin=...`) are rejected with `ILO-P011` plus a rename suggestion.

Expand All @@ -266,6 +267,8 @@ The top-level chain trap (`ILO-P102`) catches a bare `name=expr` at the top leve

The double-minus trap (`ILO-P021`) catches the silent-miscompile shape `- -<op> a b <op> c d` for `<op>` in `{+,*,/}`. Read intuitively as `-(a*b) - (c*d)` but parses as `-((a*b) - (c*d)) = -(a*b) + (c*d)` because the inner `-` greedily consumes both prefix-binop groups as binary subtract and the outer `-` falls back to unary negate. Fix by negating the sum (`- 0 +*a b *c d`) or binding first (`p=*a b;q=*c d;- 0 +p q`). Single-atom variants like `- -a b` remain accepted since they're unambiguous.

The AST depth cap (`ILO-P103`) catches deeply nested source that would otherwise blow the parser stack. Any context that compiles untrusted text - `ilo serv`, the bare-positional dispatch, the `--ast` dump - is exposed to a payload of the shape `((((...((1+1))))...))` 1000 levels deep that recurses straight through the OS thread stack. The default cap of 256 is far above anything hand-written (the in-tree examples top out under 20) and low enough to keep the worst-case stack frame in `parse_atom`/`parse_expr` inside the default 8 MB main-thread stack. Override with `--max-ast-depth N` on `ilo`, `ilo run`, `ilo check`, `ilo build`, and `ilo serv` when a legitimate program needs deeper nesting.

---

## Comments
Expand Down Expand Up @@ -1712,6 +1715,8 @@ ilo program.ilo --ast -- print parsed AST as JSON and exit
ilo --explain ILO-T004 -- print error explanation and exit
ilo help ai -- compact AI spec to stdout (= contents of ai.txt)
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)
```

**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.
Expand Down
4 changes: 2 additions & 2 deletions ai.txt

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions examples/ast-depth-cap.ilo
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- ast-depth-cap: ilo caps parser nesting at 256 by default. Untrusted source
-- (think `ilo serv`) can otherwise blow the parser stack with
-- `((((...((1+1))))...))`. Override with `--max-ast-depth N` on `ilo`,
-- `ilo run`, `ilo check`, `ilo build`, or `ilo serv` when a legitimate
-- program needs deeper nesting. Hand-written ilo rarely exceeds depth 10.

shallow>n;((((1))))

main>n;shallow

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

`ilo serv [--mcp m.json] [--tools http.json]` is a long-lived JSON request/response loop on stdin/stdout. Send `{"program":"fn p:n>n;*p 2","func":"fn","args":[21]}`, get `{"ok": 42}` or `{"error":{...}}`. Cuts process-spawn overhead to zero.

## AST depth cap

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`.

## Branching

Failures / repair: `ilo-edit-loop`. Runnable patterns: `ilo-examples`. Tools: `ilo-tools`. Engine pick: `ilo-engines`.
1 change: 1 addition & 0 deletions skills/ilo/ilo-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ description: Use this when reading ILO-XXXX error codes or fixing failures. List
- **P009 unparenthesised lambda** - wrap `(p:t>r;body)`.
- **P020 incomplete function header** - header missing `>type;body`; finish it.
- **P021 double-minus prefix-binop trap** - `- -*a b *c d` ambiguous. Use `- 0 +*a b *c d` or bind first.
- **P103 AST nesting depth exceeded** - parser refused source nesting more than 256 levels deep (DoS guard for `ilo serv`). Flatten by binding intermediates, or raise the cap with `--max-ast-depth N`.

## Type

Expand Down
12 changes: 12 additions & 0 deletions src/cli/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ pub struct Global {
/// Suppress idiomatic hints after execution.
#[arg(long = "no-hints", short = 'n', global = true)]
pub no_hints: bool,

/// Cap on AST nesting depth. Applies to every subcommand that parses source
/// (`run`, `check`, `build`, `serv`). Default 256 — far above anything
/// hand-written, low enough to keep `ilo serv` safe from `((((...))))`
/// DoS payloads against the parser stack. Raise only if a legitimate
/// program needs deeper nesting.
#[arg(long = "max-ast-depth", global = true)]
pub max_ast_depth: Option<usize>,
}

#[derive(Subcommand, Debug)]
Expand Down Expand Up @@ -874,6 +882,7 @@ mod tests {
text: false,
json: false,
no_hints: false,
max_ast_depth: 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 @@ -896,6 +905,7 @@ mod tests {
text: false,
json: true,
no_hints: false,
max_ast_depth: None,
};
assert!(g.explicit_json());
assert_eq!(g.output_mode(), OutputMode::Json);
Expand All @@ -908,6 +918,7 @@ mod tests {
text: true,
json: false,
no_hints: false,
max_ast_depth: None,
};
assert!(!g.explicit_json());
assert_eq!(g.output_mode(), OutputMode::Text);
Expand All @@ -920,6 +931,7 @@ mod tests {
text: false,
json: false,
no_hints: false,
max_ast_depth: None,
};
assert!(!g.explicit_json());
assert_eq!(g.output_mode(), OutputMode::Ansi);
Expand Down
24 changes: 24 additions & 0 deletions src/diagnostic/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,30 @@ subtract and negate it explicitly:
This diagnostic exists to catch a specific silent-miscompile shape;
single-atom variants like `- -a b` (negate of subtract over atoms) are
unambiguous and remain accepted.
"#,
},
ErrorEntry {
code: "ILO-P103",
short: "AST nesting depth exceeded",
long: r#"## ILO-P103: AST nesting depth exceeded

The parser refused a program whose expression or statement tree nests more
deeply than the configured cap (default 256). A deeply nested input is
almost always a denial-of-service payload aimed at `ilo serv` or any other
context that compiles untrusted source — `((((...((1 + 1))))...))` recurses
straight through the OS thread stack on a tree-walker parser, and pathological
verifier complexity follows from there.

The default cap of 256 is far above anything hand-written: the deepest
expression in the in-tree examples is under 20 levels. If a legitimate program
genuinely needs more, raise the cap with `--max-ast-depth N` on `ilo`,
`ilo run`, `ilo check`, `ilo build`, or `ilo serv`:

ilo --max-ast-depth 1024 run prog.ilo
ilo serv --max-ast-depth 1024

**Fix:** flatten the expression by binding intermediates, or override the cap
deliberately if the depth is real.
"#,
},
// ── Type / Verifier ──────────────────────────────────────────────────────
Expand Down
Loading
Loading