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
109 changes: 109 additions & 0 deletions SANDBOX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# ilo Capability Sandbox: Operator Guide

> CLI capability flags for multi-tenant and sandboxed deployments (ILO-59).

## Overview

ilo programs can read files, write files, make network requests, and spawn
subprocesses. In single-user / trusted contexts this is fine — the same
footprint as any scripting language. In multi-tenant deployments (agents
running untrusted ilo code on a shared server) this leaves SSRF,
arbitrary-filesystem-read, and arbitrary-command-execution open.

Capability flags give operators a per-process sandbox. Any `--allow-*` flag
switches the runtime from **permissive** (legacy default, no restrictions) to
**restricted** (only explicitly listed targets are permitted). Capabilities not
mentioned default to unrestricted when in restricted mode, so you can add a
single flag without breaking other IO.

## Flags

| Flag | Value syntax | Effect |
|------|-------------|--------|
| `--allow-net[=HOSTS]` | comma-separated hosts, `*`, or empty | Gate outbound HTTP/HTTPS |
| `--allow-read[=PATHS]` | comma-separated path prefixes, `*`, or empty | Gate file reads |
| `--allow-write[=PATHS]` | comma-separated path prefixes, `*`, or empty | Gate file writes |
| `--allow-run[=CMDS]` | comma-separated command names, `*`, or empty | Gate subprocess spawning |

**Value semantics:**

- Omitted flag → that capability is unrestricted (permissive).
- `--allow-net=*` or `--allow-net=all` → net unrestricted (explicit All).
- `--allow-net=api.example.com,cdn.example.com` → only those two hosts.
- `--allow-net=` (empty value) → all network blocked.

Once any `--allow-*` flag is present the mode is restricted; all four
dimensions are individually governed by their flag (or `Policy::All` if that
flag was omitted).

## Matching rules

**Network (`--allow-net`):** host extracted from URL (scheme and path stripped).
Exact match or leading `*.`-wildcard: `*.example.com` matches
`api.example.com` and `example.com`.

**Read / write (`--allow-read`, `--allow-write`):** path-prefix matching with
separator boundary. `/tmp` permits `/tmp/foo` but not `/tmpfoo`. Trailing
slash on the prefix is normalised.

**Run (`--allow-run`):** exact command name or basename match. `/usr/bin/ls`
is matched by `ls` in the allowlist.

## Error code

A denied capability emits `ILO-CAP-001` as the error value returned from the
builtin:

```
ILO-CAP-001 blocked by --allow-net policy: host=evil.example is not in the allowlist
```

The error is a normal ilo `R` (Result) `Err` value — programs can pattern-match
it with `?{res|er: ...}` and react programmatically. It is not a fatal abort.

## Capability matrix

| Builtin | Capability checked |
|---------|-------------------|
| `get`, `post`, `put`, `patch`, `del`, `http-get`, `http-post`, `fetch` | `--allow-net` |
| `rd`, `rd-lines`, `ls`, `lsr` | `--allow-read` |
| `wr`, `wr-lines`, `wr-app` | `--allow-write` |
| `run`, `run2` | `--allow-run` |

## Recipes

### Block all IO

```sh
ilo run --allow-net= --allow-read= --allow-write= --allow-run= untrusted.ilo
```

### Allow only outbound calls to one API

```sh
ilo run --allow-net=api.example.com trusted.ilo
```

### Read-only scratch space

```sh
ilo run --allow-read=/data --allow-write=/tmp agent.ilo
```

### Wildcard subdomain

```sh
ilo run --allow-net="*.internal.corp" service.ilo
```

## Backwards compatibility

`Caps::Permissive` is the default. Any script that does not pass `--allow-*`
runs without restriction — identical behaviour to pre-0.13.

## See also

- `examples/capability-sandbox.ilo` — runnable demo.
- `SPEC.md` — capability flags section.
- ILO-59 (Linear) — implementation ticket.
- ILO-47 (Linear) — `World` capability parameter (the language-level long-term move).
6 changes: 6 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -2071,8 +2071,14 @@ ilo --max-ast-depth N <sub> -- cap parser nesting at N (default 256; prote
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)
ilo run --allow-net[=HOSTS] <file> -- restrict outbound net to comma-separated hosts (* = all, empty = none)
ilo run --allow-read[=PATHS] <file> -- restrict file reads to comma-separated path prefixes
ilo run --allow-write[=PATHS] <file> -- restrict file writes to comma-separated path prefixes
ilo run --allow-run[=CMDS] <file> -- restrict subprocess spawning to comma-separated command names
```

**Capability flags (`ILO-CAP-001`).** `ilo run --allow-net=HOSTS --allow-read=PATHS --allow-write=PATHS --allow-run=CMDS` gates IO builtins at the process level. Any `--allow-*` flag present switches the runtime from **permissive** (default — no restrictions, full backwards compatibility) to **restricted** (only listed targets are permitted). Denial returns a normal `R` Err value with code `ILO-CAP-001`; programs can pattern-match it. Capability matrix: `get`/`post`/`put`/`patch`/`del`/`fetch` → `--allow-net`; `rd`/`rd-lines`/`ls`/`lsr` → `--allow-read`; `wr`/`wr-lines`/`wr-app` → `--allow-write`; `run`/`run2` → `--allow-run`. Value syntax: omit = unrestricted; `*` = all permitted; empty (`--allow-net=`) = all blocked; comma list = only those targets. Matching: net = hostname extracted from URL, exact or `*.domain` wildcard; read/write = path-prefix with separator boundary; run = basename or full-path match. See `SANDBOX.md` for the operator guide and `examples/capability-sandbox.ilo` for a runnable demo.

**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 were 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.
Expand Down
2 changes: 1 addition & 1 deletion ai.txt

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions examples/capability-sandbox.ilo
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-- capability-sandbox.ilo: demonstrates CLI capability flags (ILO-59).
--
-- Run with all capabilities denied (except net to httpbin.org):
-- ilo run --allow-net=httpbin.org examples/capability-sandbox.ilo
--
-- Run with no flags (permissive / legacy mode — all IO allowed):
-- ilo run examples/capability-sandbox.ilo
--
-- Run with all IO denied:
-- ilo run --allow-net= --allow-read= --allow-write= --allow-run= examples/capability-sandbox.ilo

-- permitted-read: reads a file that is inside the allowed prefix.
-- Requires: --allow-read=/tmp (or permissive mode)
permitted-read>R t t
wr "/tmp/ilo_sandbox_demo.txt" "sandbox ok"
rd "/tmp/ilo_sandbox_demo.txt"

-- denied-read: reads /etc/passwd which is outside /tmp.
-- Expected to return Err when --allow-read=/tmp is set.
denied-read>R t t
rd "/etc/passwd"

-- check-net: attempts a network GET.
-- Expected to return Err when --allow-net= (empty) is set.
check-net>R t t
get "https://httpbin.org/get"

-- main: exercises both a permitted capability (write+read in /tmp) and a
-- denied one (read outside the prefix), printing the outcomes.
main>_
res-ok = permitted-read()
?{res-ok|er: prnt +"file read denied: " er
~v: prnt +"file read ok, contents: " v}
res-deny = denied-read()
?{res-deny|er: prnt +"denied read blocked as expected: " er
~v: prnt +"WARNING: denied read returned value " v}
8 changes: 4 additions & 4 deletions src/caps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ impl Caps {
Ok(())
} else {
Err(format!(
"blocked by --allow-net policy: host={host} is not in the allowlist"
"ILO-CAP-001 blocked by --allow-net policy: host={host} is not in the allowlist"
))
}
}
Expand All @@ -109,7 +109,7 @@ impl Caps {
Ok(())
} else {
Err(format!(
"blocked by --allow-read policy: path={path} is not in the allowlist"
"ILO-CAP-001 blocked by --allow-read policy: path={path} is not in the allowlist"
))
}
}
Expand All @@ -128,7 +128,7 @@ impl Caps {
Ok(())
} else {
Err(format!(
"blocked by --allow-write policy: path={path} is not in the allowlist"
"ILO-CAP-001 blocked by --allow-write policy: path={path} is not in the allowlist"
))
}
}
Expand All @@ -149,7 +149,7 @@ impl Caps {
Ok(())
} else {
Err(format!(
"blocked by --allow-run policy: cmd={cmd} is not in the allowlist"
"ILO-CAP-001 blocked by --allow-run policy: cmd={cmd} is not in the allowlist"
))
}
}
Expand Down
16 changes: 16 additions & 0 deletions tests/capability_flags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ fn allow_net_empty_blocks_get() {
assert!(is_err_value(&tree), "tree: expected Err, got {tree:?}");
assert!(is_err_value(&vm_val), "vm: expected Err, got {vm_val:?}");
let msg = err_text(&tree);
assert!(
msg.contains("ILO-CAP-001"),
"err should include ILO-CAP-001 code, got: {msg}"
);
assert!(
msg.contains("--allow-net"),
"err should mention --allow-net, got: {msg}"
Expand Down Expand Up @@ -164,6 +168,10 @@ fn allow_read_blocks_outside_prefix() {
"vm: expected Err for /etc/passwd when read limited to /tmp"
);
let msg = err_text(&tree);
assert!(
msg.contains("ILO-CAP-001"),
"err should include ILO-CAP-001 code, got: {msg}"
);
assert!(
msg.contains("--allow-read"),
"err should mention --allow-read, got: {msg}"
Expand Down Expand Up @@ -214,6 +222,10 @@ fn allow_write_blocks_outside_prefix() {
"vm: expected Err for write outside prefix"
);
let msg = err_text(&tree);
assert!(
msg.contains("ILO-CAP-001"),
"err should include ILO-CAP-001 code, got: {msg}"
);
assert!(
msg.contains("--allow-write"),
"err should mention --allow-write, got: {msg}"
Expand All @@ -239,6 +251,10 @@ fn allow_run_empty_blocks_run() {
"expected Err when run allowlist is empty, got {result:?}"
);
let msg = err_text(&result);
assert!(
msg.contains("ILO-CAP-001"),
"err should include ILO-CAP-001 code, got: {msg}"
);
assert!(
msg.contains("--allow-run"),
"err should mention --allow-run, got: {msg}"
Expand Down
Loading