Wile is a Scheme interpreter and compiler designed to be embedded in Go programs, including to run untrusted Scheme code. Security is part of the product, not an afterthought. This document explains how to report vulnerabilities and describes the security model so embedders can make informed decisions.
Please report security issues privately. Do not open a public issue for a suspected vulnerability.
Use GitHub's private vulnerability reporting:
- Go to the repository's Security tab → Report a vulnerability (Security Advisories). This opens a private channel with the maintainers.
- If private reporting is unavailable, contact the maintainer directly rather than filing a public issue.
When reporting, please include:
- The affected version (
wile --version) or commit SHA. - A minimal Scheme program or Go embedding snippet that reproduces the issue.
- The engine configuration in use (profile, authorizer, enabled extensions).
- What you expected to be prevented and what actually happened.
- Any relevant stack trace or error output.
- Acknowledgement of your report as soon as practical.
- An assessment of severity and affected versions.
- A fix or mitigation, followed by coordinated disclosure. We prefer to publish a fix before public details, and will credit reporters who wish to be named.
Wile is an early-stage, actively developed project. Response times are best-effort, not contractual.
Security fixes land on master and ship in the next release. The latest
released version is the supported version; there are no long-term support
branches at this stage.
| Version | Supported |
|---|---|
| Latest release | ✅ |
| Older releases | ❌ |
Per the project's versioning posture, APIs may still change between minor versions. Pin a specific version if you depend on security-relevant behavior, and review the CHANGELOG when upgrading.
go.mod declares a minimum of Go 1.24, but building Wile with Go 1.26.4 or
later is recommended: Go 1.26.3 and earlier ship stdlib packages with
reachable vulnerabilities. The published release binaries are built with a
patched toolchain; embedders compiling Wile into their own programs should use
Go 1.26.4+ to avoid inheriting those CVEs.
Wile is a language-level sandbox, not an OS-level one. It runs Scheme on a bytecode VM inside your Go process. Its protections are about which capabilities Scheme code can reach, enforced in pure Go.
- Pure Go, memory-safe within a scope. No CGo and no
unsafein production code, and Scheme values are ordinary Go heap objects managed by the Go garbage collector. Wile inherits Go's memory safety for an engine that both withholds the threads capability and installs an authorizer. Outside that scope it does not: see the concurrency bullet below for the fault, and Two-layer authorization for why the authorizer clause is needed to keep threads out of reach. This is a permanent scoping of the claim, not a defect awaiting a fix — the alternative was priced (per-element boxing, or serializing every shared mutation through the owning sequencer) and declined. - No ambient host access. A freshly constructed engine exposes only the extensions you enable. Dangerous capabilities — filesystem, process execution, environment, Go interop — live in opt-in extensions. If an extension is not loaded, its primitives do not exist for Scheme code to call.
- One engine per goroutine. An
Engineis not safe for concurrent use by multiple goroutines. Within an engine, the VM coordinates its own runtime structures (continuation chains, thread scheduling, the phase and syntax registries) and SRFI-18 thread scheduling. It does not make concurrent mutation of shared Scheme objects atomic:vector-set!,set-car!/set-cdr!, record and port writes, andset!on a captured variable are plain stores. Programs that share mutable state across SRFI-18 threads must synchronize it themselves, with SRFI-18 mutexes or theatomicprimitives. The consequence is not bounded at a lost update. A Scheme value is a two-word Go interface, so a store racing a read can be observed torn — the type word of one value beside the data word of another. The Go runtime reports that asruntime.throw(unexpected fault address, SIGBUS), which is not a recoverable panic:RunResumable's recover cannot contain it and the host process dies. R7RS and SRFI-18 promise nothing about atomic mutation, but that is a statement about Scheme semantics; this one is about the host.
Sandboxing for embedded use has two orthogonal layers. The effective capability set is the intersection of the two: an operation is possible only if its extension is loaded and the authorizer allows it.
Carve-out: with no authorizer installed there is no intersection to take.
The profile-widening check has nothing to ask, so it allows: from an engine
with no authorizer, (environment '(wile kitchen-sink)) constructs a
namespace registered with the full kitchen-sink extension set regardless of
the profile the engine itself was built with. A Small engine reaches
make-thread and system (the /bin/sh -c primitive, which belongs to the
process extension Small excludes) that way. This is deliberate — an
embedder who installed no policy has accepted whatever Scheme can reach — and
it is why the memory-safety scope above names an installed authorizer as well
as the threads capability. See
Profile namespaces widen the surface
for the three cases and the rationale.
1. Profiles (WithProfile) select a bundle of extensions plus, for some
profiles, a built-in authorizer:
| Profile | Capabilities | Built-in authorizer |
|---|---|---|
Tiny |
Core computation only — no I/O, filesystem, or threads | none (open, but no risky prims) |
Console |
Port I/O, file access restricted to /tmp, virtual env map |
ConsoleAuthorizer |
ConsoleWithLoad |
Console plus eval/load confined to /tmp |
ConsoleWithLoadAuthorizer |
Small |
R7RS-small complete — file I/O, system interface; threads/interop not registered, but reachable through the carve-out above | none |
KitchenSink |
Everything — threads, Go interop, process execution, namespaces | none |
WithSandbox is an orthogonal modifier that wraps the authorizer with an
env-map restriction.
2. Fine-grained authorization (security.Authorizer) gates individual
privileged operations at runtime. Each gated operation issues an
AccessRequest{Resource, Action, Target}:
- Resources:
file,code,env,process,namespace,stream - Actions:
read,write,exec,stat,delete,load,eval,exit,exec-shell,create - Target: operation-specific — a file path, env-var name, or library name.
For
code:evalthe target is the literal<eval>or<compile>: there is no path to inspect, so a custom Authorizer must decide on the action alone. - TargetSource: the namespace
Targetis drawn from. Empty means the host OS filesystem.virtual-fs(security.SourceVirtualFS) means a path inside an embedder'sWithSourceFS, which OS path containment cannot bound — so, as withcode:eval, a custom Authorizer must decide on it rather than on the path.
stream gates the host's standard streams, which the io extension pre-opens
as current-{input,output,error}-port. Unlike the other resources it is checked
once per engine, when the port parameters are built, because the ports are
capability objects handed to Scheme at construction rather than named by the
running program. A refusal binds a closed in-memory port, so
(display …) raises and (output-port-open? (current-output-port)) is #f.
ConsoleAuthorizer, ConsoleWithLoadAuthorizer, ReadOnly* and
SandboxAuthorizer all allow the streams; DenyAll() and custom authorizers
can refuse them.
code:load (run code from a resolved file path) and code:eval (compile and
run an in-memory datum, i.e. eval/compile) are separate actions. An
Authorizer that gates only code:load leaves (eval …) open.
file carries the chmod triple read / write / exec on one target, and a
custom Authorizer must decide all three: confining reads and writes while
letting exec through confines nothing, because a binary outside the root is a
general-purpose unconfined file accessor. Any primitive whose argument denotes a
host path files it under file whatever else it also asks — process-spawn and
system gate file:exec on the resolved binary and on the child's start
directory, on top of process:exec/process:exec-shell, and
set-current-directory! gates file:write on its destination.
Gate sites include file primitives, the system/process and eval extensions,
include, and library import. Built-in authorizers include DenyAll(),
ReadOnly(), ConsoleAuthorizer(), ConsoleWithLoadAuthorizer(),
SandboxAuthorizer(envPrefix), and FilesystemRoot(root), plus the
virtual-filesystem opt-ins FilesystemRootWithVirtualSources(root) and
ConsoleWithLoadAllowingVirtualSources(); All(...)
composes several into one (all must allow). Set one with WithAuthorizer,
or get one implicitly from a profile. Custom policies implement the
one-method Authorizer interface.
Open by default. When no authorizer is configured, authorization checks allow the operation. A bare
NewEngine(ctx)is safe mainly because it loads no risky extensions — not because anything is denied. If you enable I/O, process, or interop extensions, you must also set a restrictive profile or authorizer.
A denial is not a Scheme condition. guard and with-exception-handler
cannot absorb one; it terminates the evaluation and reaches the host as an error
satisfying errors.Is(err, security.ErrAccessDenied). An embedder who wants
Scheme to handle a refusal composes a permissive authorizer for that operation
and lets the primitive's own file-error surface, or catches at the host
boundary around Eval/EvalMultiple.
To bound runaway or adversarial programs:
WithMaxCallDepth— caps continuation/recursion depth.WithMaxStackSize— caps the evaluation stack.- Context cancellation and timeouts — the VM checks
ctxduring execution, socontext.WithTimeout/WithCancel(and the built-in timer interrupts) preempt long-running code.
Be explicit about the boundary. The language-level sandbox does not by itself defend against:
- Resource exhaustion beyond the limits above — e.g. large allocations
causing memory pressure, or CPU spent inside primitives. Always run
untrusted code with a
contextdeadline and the call-depth/stack limits set. - Go interop escape. If you enable the Go interop extension, Scheme code can reach the Go values and functions you expose. Do not expose capabilities you are unwilling to grant to the script.
- OS-level isolation. Wile does not use chroot, namespaces, or seccomp.
The
/tmpconfinement in the Console profiles is enforced by the authorizer's path checks, not by the operating system.
For genuinely hostile input, combine Wile's sandbox with OS-level isolation (containers, seccomp-bpf, a restricted user, resource cgroups) and a strict authorizer that denies by default.
The standalone wile command-line tool is a developer utility. It runs with
full host access (all extensions, OS filesystem) and is not sandboxed.
The sandboxing described here is for the embedding API, not the CLI.
docs/security/sandboxing.md— detailed sandboxing and authorization guide.docs/extensions/architecture.md— extension system and capability surface.- Package
security/— theAuthorizerinterface and built-in authorizers.