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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased]

### Security
- **MCP file resources are restricted to bounded WFL sources inside the configured
workspace.** `resources/read` now canonicalizes file URIs, rejects traversal and
symlink escapes (including `.wflcfg`), caps returned source at 4 MiB, and no
longer echoes request or response bodies into diagnostic logs.
- Unsupported database URL errors no longer echo the full connection URL,
preventing embedded credentials from being disclosed in diagnostics.
- Package filesystem operations now enforce the manifest's package-name rules,
reject symlinked cache/install roots and targets, verify canonical directory
containment before recursive deletion, and prevent archive extraction through
Expand Down Expand Up @@ -38,6 +44,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- **Registry login supports an explicit registry address.** `wfl login
[registry]` scopes a token to that HTTPS origin, mismatched logins are
rejected, and `wfl logout` can recover malformed or incomplete credentials.
- **Cyclic values no longer abort the interpreter during display, diagnostics,
or isolated-module cloning.** List/object formatting now detects cycles and
caps nesting depth, while deep clones preserve cycles and shared references
inside the cloned graph.
- **Subprocess policy is enforced on every process launch** (shell path and
direct-exec / `with arguments` path). Previously, `shell_execution_mode` and
related checks ran only when the engine believed a shell was required, so
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ regex = "1.13.0"
log = "0.4.33"
rustyline = "18.0.1"
tokio = { version = "1.52.3", features = ["full"] }
reqwest = { version = "0.13.4", features = ["json"] }
reqwest = { version = "0.13.4", features = ["json", "stream"] }
encoding_rs = "0.8.35"
# sqlx 0.9 split the old `runtime-tokio-rustls` feature into a separate runtime
# and TLS backend; `tls-rustls` aliases the ring-backed rustls stack we used before.
sqlx = { version = "0.9.0", features = ["runtime-tokio", "tls-rustls", "sqlite", "mysql", "postgres", "chrono"] }
Expand Down
41 changes: 41 additions & 0 deletions Dev diary/2026-07-16-cycle-safe-values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Dev Diary — Cycle-safe values (2026-07-16)

## Context

Lists and objects are reference-counted mutable values, so valid WFL can build
self-referential and mutually recursive graphs. Value equality already handled
those graphs, but `Display`, `Debug`, and the deep clone used for module
isolation still traversed them recursively without cycle detection. Displaying
a cyclic value (or including one in a diagnostic) could therefore exhaust the
native stack, and cloning one could do the same during isolated lookup.

## What changed

- `Display` and `Debug` now carry per-format traversal state. A container that
reappears on the active path renders as `<cycle>`; shared acyclic values still
render normally each time they appear.
- Formatting stops after 64 nested containers and renders `<max-depth>`, keeping
very deep acyclic graphs comfortably below the native stack limit.
- Formatting uses `try_borrow`, so an incidental outstanding mutable borrow is
rendered as a marker instead of causing a `RefCell` panic.
- `Value::deep_clone` now memoizes list, object, and container-instance
placeholders before cloning their contents. Cycles point into the cloned
graph, shared references remain shared within that graph, and the clone stays
isolated from the source.
- Container parent links are cloned through the same memo instead of retaining
a reference into the source graph.

## Compatibility

Acyclic values below the depth limit retain their existing display and debug
forms. Only values that previously recursed indefinitely, exceeded the new
nesting guard, or were formatted while mutably borrowed receive marker text.

## Tests

- Rust-level self-cycle and list/object mutual-cycle formatting regressions.
- Deep-clone assertions for source isolation, back-reference preservation, and
shared identity.
- A depth-bound regression for acyclic nesting.
- An interpreter regression that constructs a self-referential list with WFL's
`push` statement and displays it as `[<cycle>]` without aborting.
7 changes: 6 additions & 1 deletion Docs/04-advanced-features/file-io.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,12 @@ close file <handle>
> [`reserved-keywords.md`](../reference/reserved-keywords.md) for the full list
> and the always-reserved vs. contextual distinction.

Binary reads and writes are capped at 50 MB per operation as a safety limit.
Text and binary reads are capped at 50 MiB per operation by default. The cap is
enforced as bytes stream in (including for special files without a finite
metadata length), and applies to `read N bytes` before its buffer is allocated.
Set `max_file_read_size` in `.wflcfg` to tune the limit; an oversized read raises
a catchable resource-limit error. Binary byte-list writes retain their existing
50 MiB safety check.

## Directory Operations

Expand Down
9 changes: 9 additions & 0 deletions Docs/04-advanced-features/interoperability.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ Non-2xx statuses are not errors — check `resp.ok` or `resp.status`
yourself. Network failures (DNS, connection refused) still raise errors you
can `try`/`catch`.

Outbound responses are streamed and decoded into a bounded buffer. The
`web_server_max_response_size` setting (64 MiB by default) limits the response
body for `read content` and `read response`, both as received and after text
decoding. The limit includes chunked responses with no declared length. Outside
a `main loop`, the connection and body read share the script's remaining
`timeout_seconds`; inside a lifetime-exempt `main loop`, each request gets a
fresh timeout of that duration. Cooperative cancellation also interrupts a
request that is waiting on the remote peer.

**Note:** inside an `open url` statement the words `method`, `headers`, and
`body` introduce clauses, so use different variable names there (e.g.
`request_headers`, `payload`).
Expand Down
19 changes: 19 additions & 0 deletions Docs/04-advanced-features/subprocess-execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ shell_execution_mode = sanitized
- Policy applies to **both** the shell form and the `with arguments` form.
Passing arguments is safer against injection *after* a program is allowed;
it is not a bypass of the policy.
- `allowlist_only` permits direct execution only. Shell chaining, pipes,
redirects, expansion, and other shell features are blocked even when the
first command is listed.
- Name-only allowlist entries do not authorize explicit paths with the same
basename. Allow an executable path explicitly when a script must use one.
- Avoid allowlisting shells and interpreters such as `sh`, `cmd.exe`,
PowerShell, or Python: their ordinary arguments can execute additional code.

See [Configuration Reference](../reference/configuration-reference.md#security-settings)
for full option details.
Expand Down Expand Up @@ -133,6 +140,18 @@ wait for read output from process proc as output_data
display "Output: " with output_data
```

Captured stdout and stderr each retain at most `max_buffer_size_bytes` raw
stream bytes (10 MiB by default) for both `execute command` and
`spawn command`. When a command produces more, WFL continues draining the
stream so the child cannot deadlock, retains only its most recent bytes, and
prints a truncation warning. Malformed UTF-8 replacement can make the returned
WFL text larger than the raw-byte count, but only by a bounded factor.
Foreground commands also observe the run's `timeout_seconds` deadline and
cooperative cancellation through both process execution and pipe draining; WFL
terminates and reaps a child that stalls past either one. A long-lived
`main loop` is exempt from the run-wide deadline, but each foreground command
inside it still receives a fresh `timeout_seconds` window.

## Executing WFL Files In-Process

`execute command` starts a separate program. To run another **WFL file**
Expand Down
56 changes: 47 additions & 9 deletions Docs/reference/configuration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ All keys currently loaded from config files, with defaults.
|---|---|---|---|
| `allow_shell_execution` | bool | `false` | Master switch for all process launches |
| `shell_execution_mode` | string | `forbidden` | `forbidden` / `allowlist_only` / `sanitized` / `unrestricted` |
| `allowed_shell_commands` | comma-list | *(empty)* | Program basenames allowed in `allowlist_only` mode |
| `allowed_shell_commands` | comma-list | *(empty)* | Program names or explicit paths allowed in `allowlist_only` mode |
| `warn_on_shell_execution` | bool | `true` | Warn whenever a shell command runs |

### Subprocess resources
Expand All @@ -211,7 +211,7 @@ All keys currently loaded from config files, with defaults.
| `web_server_tls_cert_file` | path | *(none)* | Default PEM cert for bare `listen … secured` |
| `web_server_tls_key_file` | path | *(none)* | Default PEM key for bare `listen … secured` |
| `web_server_max_body_size` | integer ≥ 1 | `1048576` (1 MiB) | Max HTTP request body size (bytes); enforced while streaming (chunked-safe) |
| `web_server_max_response_size` | integer ≥ 1 | `67108864` (64 MiB) | Max HTTP response body size (bytes) |
| `web_server_max_response_size` | integer ≥ 1 | `67108864` (64 MiB) | Max handler or outbound HTTP response body size (bytes) |
| `web_server_request_queue_bound` | integer ≥ 1 | `256` | Max queued HTTP requests before shedding with 503 |
| `web_server_response_timeout_seconds` | integer ≥ 0 | `300` | Seconds to await a handler before shedding with 504; `0` disables |
| `web_socket_queue_bound` | integer ≥ 1 | `1024` | Max queued frames/events per WebSocket channel before shedding |
Expand All @@ -235,6 +235,7 @@ clean, catchable error instead of a crash or unbounded memory growth.
| `max_pattern_steps` | integer ≥ 1 | `5000000` | Max pattern-matching transitions per match (ReDoS guard) |
| `max_pattern_states` | integer ≥ 1 | `10000` | Max simultaneously-active pattern states per match |
| `max_source_size` | integer ≥ 1 | `67108864` (64 MiB) | Max WFL source-file size (bytes) |
| `max_file_read_size` | integer ≥ 1 | `52428800` (50 MiB) | Max bytes buffered by one text or binary file read |

The wall-clock deadline (`timeout_seconds`), request body/response ceilings, and
the HTTP/WebSocket queue and connection bounds above are all part of the same
Expand All @@ -248,7 +249,11 @@ budget.

#### `timeout_seconds`

Maximum execution time for a WFL script in seconds. The script terminates if it exceeds this limit.
Maximum execution time for a WFL script in seconds. Outside a `main loop`, an
outbound `open url` request (connection, headers, and response body) consumes
the run's remaining time. A `main loop` remains exempt from the lifetime limit,
but each outbound request inside it gets this duration as a fresh finite timeout
so a stalled remote peer cannot wedge the server indefinitely.

- **Type:** Integer (minimum: 1)
- **Default:** `60`
Expand Down Expand Up @@ -389,7 +394,7 @@ Applied to every launch, not only shell metacharacter forms.
- **Default:** `forbidden`
- **Options:**
- `forbidden` — no process execution allowed (most secure)
- `allowlist_only` — only programs whose basename is in `allowed_shell_commands` may run
- `allowlist_only` — only direct-exec programs in `allowed_shell_commands` may run; shell features are rejected
- `sanitized` — any program may run; shell features produce warnings
- `unrestricted` — any program may run with shell; not recommended for production
- **Example:** `shell_execution_mode = allowlist_only`
Expand All @@ -411,9 +416,19 @@ allowed_shell_commands = echo, ls, git

#### `allowed_shell_commands`

Comma-separated list of allowed **program basenames** when using
`allowlist_only` mode. Matching uses the basename of the program path
(`/bin/echo` matches `echo`). On Windows, comparison is case-insensitive.
Comma-separated list of allowed program names or explicit executable paths when
using `allowlist_only` mode. A name such as `echo` authorizes only a name-only
invocation resolved through the host process's `PATH`; it does not authorize
`./echo`, `/tmp/echo`, or another caller-selected path with the same basename.
Path-bearing commands require a path-bearing allowlist entry resolving to the
same executable. On Windows, comparison is case-insensitive.

`allowlist_only` never invokes a shell. Commands containing pipes, redirects,
expansion, command chaining, or other shell features are rejected even when
their first program is allowlisted. Pass data through `with arguments`; opt in
to `sanitized` or `unrestricted` only when shell syntax is genuinely required.
Do not allowlist a shell or interpreter (`sh`, `cmd.exe`, PowerShell, Python,
and similar) unless you intend its arguments to be able to execute code.

- **Type:** Comma-separated strings
- **Default:** *(empty)*
Expand All @@ -439,7 +454,13 @@ Maximum number of subprocesses that can run simultaneously.

#### `max_buffer_size_bytes`

Maximum size of output buffers for subprocess stdout/stderr, in bytes.
Maximum number of raw stream bytes retained in each stdout/stderr output
buffer. This limit applies to both foreground `execute command` capture and
background `spawn command` capture. If a stream exceeds the limit, WFL drains
it without growing memory, keeps the most recent bytes, and emits a truncation
warning. A value of `0` discards all captured output. Converting malformed
UTF-8 to WFL text may expand the returned text, but remains a bounded multiple
of this raw-byte ceiling.

- **Type:** Integer
- **Default:** `10485760` (10 MiB)
Expand Down Expand Up @@ -524,7 +545,12 @@ Because request handlers run one at a time (see [Web Servers → Limitations](..

#### `web_server_max_response_size`

Maximum HTTP response body a handler may `respond with`, in bytes. A larger response is refused (the handler gets a runtime error) rather than streaming an unbounded payload to the client.
Maximum HTTP response body size, in bytes, for both directions: content a
handler may `respond with`, and content an outbound `open url` statement may
read. A larger handler response is refused; a larger outbound response is
stopped when either its received bytes or decoded UTF-8 text reaches this
limit. This applies even when the remote server uses chunked transfer encoding
or omits `Content-Length`.

- **Type:** Integer (bytes, at least 1)
- **Default:** `67108864` (64 MiB)
Expand Down Expand Up @@ -641,6 +667,18 @@ Maximum size, in bytes, of a WFL source file. A larger file is refused before it
- **Default:** `67108864` (64 MiB)
- **Example:** `max_source_size = 1048576` # 1 MiB

#### `max_file_read_size`

Maximum bytes one `read content`, `read binary`, or `read N bytes` operation may
buffer. The ceiling is enforced while the file is read, so streams and special
files whose metadata has no useful length cannot grow memory without bound. A
larger read fails with a catchable resource-limit error; exact-limit reads are
accepted.

- **Type:** Integer (bytes, at least 1)
- **Default:** `52428800` (50 MiB)
- **Example:** `max_file_read_size = 10485760` # 10 MiB

Each of these positive-integer keys rejects `0` and non-numeric values, keeping the default and logging a warning.

---
Expand Down
1 change: 1 addition & 0 deletions fuzz/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 16 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ pub struct WflConfig {
/// server sheds new requests with a 503 instead of growing memory without
/// bound. Default 256; must be at least 1.
pub web_server_request_queue_bound: usize,
/// Maximum HTTP response body size in bytes. A handler that tries to send a
/// larger body is refused with a 500 rather than streaming an unbounded
/// payload. Feeds `ExecutionBudget`. Default 64 MiB.
/// Maximum HTTP response body size in bytes, for both handler responses and
/// bodies read by outbound `open url` statements. A larger body is refused
/// rather than buffered/streamed without bound. Feeds `ExecutionBudget`.
/// Default 64 MiB.
pub web_server_max_response_size: usize,
/// Maximum seconds the transport waits for a handler to answer an accepted
/// HTTP request before shedding it with 504 and releasing its in-flight
Expand Down Expand Up @@ -84,6 +85,9 @@ pub struct WflConfig {
/// Maximum WFL source-file size in bytes. Feeds `ExecutionBudget`.
/// Default 64 MiB.
pub max_source_size: usize,
/// Maximum bytes a single text or binary file-read operation may buffer.
/// Feeds `ExecutionBudget`. Default 50 MiB.
pub max_file_read_size: usize,
/// Maximum queued frames/events per WebSocket channel before shedding.
/// Feeds `ExecutionBudget`. Default 1024; must be at least 1.
pub web_socket_queue_bound: usize,
Expand Down Expand Up @@ -198,6 +202,9 @@ impl Default for WflConfig {
max_pattern_steps: 5_000_000,
max_pattern_states: 10_000,
max_source_size: 64 * 1024 * 1024,
// Preserve the documented per-operation binary-read ceiling and
// extend the same OOM protection to text reads.
max_file_read_size: 50 * 1024 * 1024,
web_socket_queue_bound: 1_024,
web_socket_max_connections: 1_024,
web_socket_max_message_size: 1_048_576,
Expand Down Expand Up @@ -858,6 +865,12 @@ fn parse_config_text(config: &mut WflConfig, text: &str, file: &Path) {
"max_source_size" => {
set_positive_usize(&mut config.max_source_size, "max_source_size", value, file)
}
"max_file_read_size" => set_positive_usize(
&mut config.max_file_read_size,
"max_file_read_size",
value,
file,
),
"web_socket_queue_bound" => set_positive_usize(
&mut config.web_socket_queue_bound,
"web_socket_queue_bound",
Expand Down
Loading
Loading