Rugo supports opt-in process sandboxing using Linux Landlock, a kernel-native security module that lets unprivileged processes restrict themselves. When a Rugo script declares a sandbox directive, the compiled binary self-restricts before executing any user code β no root, no containers, no SELinux/AppArmor configs needed.
Landlock is a self-sandboxing mechanism: the process applies restrictions to itself, and these restrictions are inherited by all child processes (including shell commands). Once applied, restrictions cannot be relaxed β only further restricted.
Rugo leverages go-landlock to inject Landlock syscalls into the compiled binary. The sandboxing code runs in main() immediately after the panic handler and before any user code executes.
.rugo source (with sandbox directive)
β
βΌ
Parser recognizes SandboxStmt
β
βΌ
Codegen emits go-landlock calls in main()
β
βΌ
Generated go.mod includes go-landlock dependency
β
βΌ
Compiled binary self-restricts on startup
| Feature | Minimum Kernel | Landlock ABI |
|---|---|---|
| Filesystem sandboxing | Linux 5.13 | v1 |
| File referring/reparenting | Linux 5.19 | v2 |
| File truncation control | Linux 6.2 | v3 |
| TCP network restrictions | Linux 6.7 | v4 |
| IOCTL on special files | Linux 6.10 | v5 |
Rugo uses landlock.V5.BestEffort() which gracefully degrades on older kernels β the highest available ABI version is used automatically.
On non-Linux systems, the sandbox directive is a no-op that prints a warning to stderr:
rugo: warning: sandbox requires Linux with Landlock support, running unrestricted
The sandbox directive is a top-level statement. It must appear before any code β only use, import, and require may precede it.
use "os"
import "strings"
sandbox ro: ["/etc"], rw: ["/tmp"]
# code goes here# Bare sandbox: deny ALL filesystem and network access
sandbox
# With permissions (single values)
sandbox ro: "/etc", rw: "/tmp"
# With permissions (arrays)
sandbox ro: ["/etc", "/usr/share"], rw: ["/tmp", "/var/log"]
# Full example with all permission types
sandbox ro: ["/etc"], rw: ["/tmp"], rox: ["/usr/bin"], rwx: ["/var/data"], connect: [80, 443], bind: 8080, env: ["PATH", "HOME"]Path strings in sandbox permissions support $VAR and ${VAR} expansion at runtime via Go's os.ExpandEnv. This lets you write portable sandbox rules without hardcoding user-specific paths:
# Expands $HOME at runtime
sandbox ro: ["$HOME/.config"], rw: ["$TMPDIR"], rox: ["/usr", "/lib"]
# ${VAR} syntax also works
sandbox ro: ["${XDG_CONFIG_HOME}"], rw: ["/tmp"]Expansion happens just before Landlock rules are applied β after env filtering (if env: is specified) but before any user code runs. If a variable is unset, it expands to an empty string.
| Keyword | Access | Use Case |
|---|---|---|
ro |
Read-only | Config files, data files |
rw |
Read + write | Temp dirs, log files, output dirs |
rox |
Read + execute | Binary directories (/usr/bin, /usr/lib) |
rwx |
Read + write + execute | Plugin dirs, build dirs |
connect |
TCP connect to port | HTTP clients, database connections |
bind |
TCP bind to port | Servers, listeners |
env |
Environment variable allowlist | Restrict env var access |
Each permission type maps to specific Landlock access rights:
ro (read-only)
- Files:
ReadFile - Directories:
ReadFile,ReadDir
rw (read-write)
- Files:
ReadFile,WriteFile,Truncate,IoctlDev - Directories: all of the above plus
ReadDir,RemoveDir,RemoveFile,MakeChar,MakeDir,MakeReg,MakeSock,MakeFifo,MakeBlock,MakeSym,Refer
rox (read + execute)
- Files:
Execute,ReadFile - Directories:
Execute,ReadFile,ReadDir
rwx (read + write + execute)
- All
rwrights plusExecute
The env permission restricts which environment variables the sandboxed process can access. This is a userspace mechanism (not Landlock) β it works on all platforms.
env: ["PATH", "HOME"]β clears all env vars, then restores only the listed onesenv: "PATH"β single value form- Environment filtering is opt-in β if
envis not specified, all env vars are inherited - Env clearing runs before Landlock restrictions and before any user code
# Only allow PATH and HOME
sandbox env: ["PATH", "HOME"]
# Combine with filesystem and network restrictions
sandbox ro: ["/etc"], env: ["PATH", "HOME", "LANG"], connect: [443]You can apply sandbox restrictions from the command line without modifying the script:
# Bare sandbox (deny everything)
rugo run --sandbox script.rugo
# With permissions (repeatable flags)
rugo run --sandbox --ro /etc --ro /usr/share --rw /tmp --connect 443 script.rugo
# With environment variable filtering
rugo run --sandbox --env PATH --env HOME --ro /etc script.rugo
# Build a sandboxed binary
rugo build --sandbox --ro /etc --rox /usr -o mybinary script.rugoAvailable flags (all repeatable):
| Flag | Description |
|---|---|
--sandbox |
Enable sandboxing (required to activate) |
--ro PATH |
Allow read-only access |
--rw PATH |
Allow read-write access |
--rox PATH |
Allow read + execute access |
--rwx PATH |
Allow read + write + execute access |
--connect PORT |
Allow TCP connections to port |
--bind PORT |
Allow TCP bind to port |
--env NAME |
Allow environment variable (clears all others) |
When both a script directive and CLI flags are present, CLI flags override the script directive entirely. The script's sandbox permissions are ignored.
# script.rugo
sandbox ro: ["/etc", "/tmp"] # β ignored when CLI flags are used
puts("hello")# CLI overrides: only /etc is allowed, /tmp is denied
rugo run --sandbox --ro /etc script.rugosandboxmust appear before any other code. Onlyuse,import, andrequiremay precede it.- Only one
sandboxdirective is allowed per program. sandboxis not allowed in required files β it must be in the main entry file.sandboxcannot appear inside functions, blocks, or control flow.
# β Valid
use "os"
import "strings"
sandbox ro: ["/etc"]
puts("ok")
# β Invalid β def before sandbox
def helper()
return 1
end
sandbox ro: ["/etc"]
# β Invalid β sandbox inside function
def foo()
sandbox ro: ["/etc"]
endLandlock restricts content access operations:
open(),read(),write(),execute(),truncate()- Directory operations: create/remove files, create directories, symlinks
- Network: TCP
bind()andconnect()
stat()/lstat(): File metadata queries always succeed.os.file_exists()returnstrueeven for paths outside the sandbox.- Existing file descriptors: Files opened before sandboxing are not affected.
- UDP and other protocols: Only TCP is restricted (as of Landlock ABI v5).
- Process operations: fork, exec (of allowed binaries), signals, etc.
Landlock restricts access to the target of a symlink, not the symlink itself. If /etc/os-release is a symlink to /usr/lib/os-release, you need to allow /usr/lib (or the specific file), not just /etc.
Shell commands (backticks, os.exec()) run as child processes that inherit sandbox restrictions. For shell commands to work, you typically need:
sandbox rox: ["/usr", "/lib"], rw: ["/dev/null"], ro: ["/etc"]/usrand/lib: executable binaries and shared libraries/dev/null: required by shell I/O redirection/etc: often needed for DNS resolution (/etc/resolv.conf), but note symlinks
Rugo does not automatically add any paths. If your script uses shell commands, you must explicitly allow every path needed. This is by design β the sandbox is only useful if you understand and control what it allows.
Rugo uses Landlock's best-effort mode by default. This means:
- On Linux 6.10+: Full filesystem + network + IOCTL restrictions (ABI v5)
- On Linux 6.7-6.9: Filesystem + network, no IOCTL control (ABI v4)
- On Linux 5.13-6.6: Filesystem only, no network restrictions
- On older kernels: Sandbox is a no-op (warning printed)
If Landlock fails to apply, a warning is printed to stderr but the program continues running unrestricted.
sandbox ro: ["/etc"]
use "os"
# Can stat /etc files
if os.file_exists("/etc/hosts")
puts("hosts file exists")
end
# Cannot write, execute, or access networksandbox ro: ["/etc"], rox: ["/usr", "/lib"], rw: ["/dev/null"], connect: [443]
use "http"
# Can only connect to port 443 (HTTPS)
response = http.get("https://example.com")
puts(response["body"])sandbox ro: ["/etc", "/var/www"], rox: ["/usr", "/lib"], bind: [8080], connect: [5432]
use "web"
# Bind to port 8080, connect to PostgreSQL on 5432
# Read-only access to static files
web.get("/", "index")
def index(req)
return {status: 200, body: "hello"}
end
web.run(8080)sandbox ro: ["/etc"], env: ["PATH", "HOME", "LANG"]
import "os"
# Only PATH, HOME, and LANG are available
puts(os.getenv("HOME"))
# All other env vars return empty string
# API keys, tokens, secrets are not accessible- Use
rugo emitto inspect generated code:rugo emit script.rugo | grep rugo_sandboxshows the exact Landlock rules being applied. - Check symlinks:
readlink -f /path/to/fileto find the real target path. - Add paths incrementally: Start with a broad sandbox and narrow it down.
- Shell commands: Remember
/usr,/lib, and/dev/nullare usually needed.
The script is running on a non-Linux system. The sandbox directive is silently skipped and the script runs unrestricted.
# Check if Landlock is enabled in your kernel
cat /sys/kernel/security/lsm
# Should include "landlock" in the list
# Check kernel version (need 5.13+ for basic, 6.7+ for network)
uname -rIf restrictions don't seem to apply:
- Verify Landlock is in the LSM list:
cat /sys/kernel/security/lsm - Check that you're on a supported kernel version
- Some container runtimes may disable Landlock β check your container security profile
The sandbox implementation touches these parts of the Rugo codebase:
- Grammar:
parser/rugo.ebnfβSandboxStmt,SandboxPerm,SandboxListrules - AST:
ast/nodes.goβSandboxStmtnode withRO,RW,ROX,RWX,Connect,Bind,Envfields - Walker:
ast/walker.goβwalkSandboxStmt()and permission parsing helpers - Preprocessor:
ast/preprocess.goβsandboxkeyword registration, colon syntax exemption, semicolon separator injection - Codegen:
compiler/codegen.goβwriteSandboxRuntime()(helper functions) andwriteSandboxApply()(main() injection) - Compiler:
compiler/compiler.goβSandboxConfigtype, go-landlock dependency injection ingoModContent() - CLI:
cmd/cmd.goβparseSandboxFlags()for--sandboxflag extraction
When a sandbox directive is present, the codegen produces:
import (
"runtime"
"github.com/landlock-lsm/go-landlock/landlock"
llsyscall "github.com/landlock-lsm/go-landlock/landlock/syscall"
)
// Helper functions for access right construction
func rugo_sandbox_fs_ro(dir bool) landlock.AccessFSSet { ... }
func rugo_sandbox_fs_rw(dir bool) landlock.AccessFSSet { ... }
func rugo_sandbox_fs_rox(dir bool) landlock.AccessFSSet { ... }
func rugo_sandbox_fs_rwx(dir bool) landlock.AccessFSSet { ... }
func rugo_sandbox_is_dir(path string) bool { ... }
func main() {
defer func() { /* panic handler */ }()
// Environment filtering (only when env: is specified)
saved_0 := os.Getenv("PATH")
os.Clearenv()
if saved_0 != "" { os.Setenv("PATH", saved_0) }
if runtime.GOOS != "linux" {
// warn and continue unrestricted
} else {
cfg := landlock.V5.BestEffort()
// Build and apply rules...
cfg.RestrictPaths(fsRules...)
cfg.RestrictNet(netRules...)
}
// User code runs here, fully sandboxed
}The sandbox feature adds two Go module dependencies to the generated program:
github.com/landlock-lsm/go-landlockβ Go bindings for Landlock LSMkernel.org/pub/linux/libs/security/libcap/psxβ Required by go-landlock for thread-safe syscalls
These are only added to the generated go.mod when a sandbox directive is present (either in the script or via CLI flags).