Acquis 10 - Permissions
Contents (15)
The pledge function provides capability-based sandboxing for Lus scripts. Permissions must be explicitly granted before sensitive operations like file access, network requests, or command execution can occur.
Basic usage#
Grant permissions with the pledge function:
-- Grant filesystem access
pledge("fs")
-- Grant network access
pledge("network")
-- Grant command execution
pledge("exec")
Without the appropriate permission, operations are denied:
-- Without pledge("fs"):
local ok, err = catch fs.list("/tmp")
assert(not ok) -- permission denied
-- With pledge("fs"):
pledge("fs")
local files = fs.list("/tmp") -- works
Permission structure#
Permissions use a hierarchical naming scheme:
-- Base permission: grants all sub-permissions
pledge("fs") -- grants fs:read, fs:write, etc.
pledge("network") -- grants network:tcp, network:http, etc.
-- Specific sub-permission: grants only that capability
pledge("fs:read") -- only reading, not writing
pledge("network:http") -- only HTTP, not raw TCP
Value restrictions#
Permissions can include path or URL restrictions:
-- Grant read access only to /tmp
pledge("fs:read=/tmp")
-- Grant read access to all paths under /home
pledge("fs:read=/home/*")
-- Grant HTTP access to specific domain
pledge("network:http=api.example.com/*")
Glob patterns are supported for path matching. On systems with symlinks, paths are canonicalized before matching (e.g., /tmp resolves to /private/tmp on macOS).
Permission rejection#
Use the ~ prefix to permanently reject a permission:
-- Reject network access
pledge("~network")
-- Later: cannot grant what was rejected
local ok = pledge("network") -- returns false
This is useful for host applications that want to restrict scripts from ever acquiring certain capabilities.
Sealing permissions#
The seal permission prevents any future permission changes:
pledge("fs")
pledge("network")
pledge("seal")
-- After seal, new permissions are denied
local ok = pledge("exec") -- returns false
This provides defense-in-depth by locking down capabilities early in script execution.
Permission checks#
The pledge function returns true on success, false on failure:
if pledge("fs") then
print("Filesystem access granted")
else
print("Filesystem access denied")
end
For sealed or rejected permissions, pledge returns false without erroring. For invalid permission names, it raises an error.
CLI usage#
Grant permissions from the command line:
# Grant single permission
lus -Pfs script.lus
lus --pledge=fs script.lus
# Grant multiple permissions
lus -Pfs -Pnetwork script.lus
# Grant with restrictions
lus -Pfs:read=/tmp script.lus
# Reject permissions
lus -P~exec script.lus
Scripts can then request only what was already granted:
-- script.lus (run with: lus -Pfs:read=/tmp script.lus)
pledge("fs:read=/tmp") -- granted
pledge("fs:write") -- denied, not in CLI grant
Coroutine inheritance#
When a coroutine is created, it receives a copy of its parent’s permissions. This copy is independent; changes in the coroutine don’t affect the parent, and vice versa.
pledge("fs")
pledge("network")
local co = coroutine.create(function()
fs.list("/tmp") -- inherits fs permission
-- Restrict our copy further
pledge("~network") -- reject network in this coroutine
pledge("seal") -- seal this coroutine's permissions
-- network is now denied in this coroutine
local ok = pledge("network")
assert(not ok)
end)
coroutine.resume(co)
-- Parent still has network access
pledge("network:http") -- works fine
Sandboxing with coroutines#
This copy-on-create behavior enables sandboxing patterns where untrusted code runs with restricted permissions:
-- Parent has broad permissions
pledge("fs")
pledge("network")
pledge("exec")
local function run_sandboxed(fn, allowed_perms)
local co = coroutine.create(function()
-- Reject everything first
pledge("~fs")
pledge("~network")
pledge("~exec")
-- Then grant only what's allowed
for _, perm in ipairs(allowed_perms) do
pledge(perm)
end
pledge("seal") -- lock it down
fn() -- run the untrusted code
end)
return coroutine.resume(co)
end
-- Run untrusted code with only fs:read access
run_sandboxed(function()
local files = fs.list("/tmp") -- allowed
local ok = catch fs.remove("/tmp/foo") -- denied
end, {"fs:read"})
Progressive restriction#
Coroutines can progressively restrict permissions as they delegate to less trusted code:
pledge("fs")
pledge("network")
-- First level: can read/write files
local level1 = coroutine.create(function()
pledge("~exec") -- no command execution
pledge("~network") -- no network
-- Second level: read-only filesystem
local level2 = coroutine.create(function()
pledge("~fs:write") -- remove write capability
pledge("seal")
-- Can only read files now
local data = io.open("/etc/hosts"):read("*a")
local ok = catch io.open("/tmp/x", "w") -- denied
end)
coroutine.resume(level2)
end)
coroutine.resume(level1)
Note: Permission changes in a coroutine are completely isolated. The parent thread’s permissions remain unchanged regardless of what the coroutine does with its copy.
Available permissions#
| Permission | Description |
|---|---|
fs | All filesystem operations |
fs:read | Reading files and directories |
fs:write | Creating, modifying, deleting files |
load | Loading code at runtime (load, loadfile, dofile, require) |
exec | Running external commands (os.execute, io.popen) |
env | Reading environment variables (os.getenv) |
network | All network operations |
network:tcp | TCP socket connections |
network:udp | UDP socket operations |
network:http | HTTP/HTTPS requests |
The
loadfile,dofile, andrequirefunctions require bothfs:read(to read the file) andload(to execute the code). Theloadfunction requires onlyload. Loading native modules (package.loadlib,requireof C modules) also requires both. Theiolibrary is gated by thefspermissions:io.openand friends needfs:read/fs:writeas appropriate, andio.tmpfileandos.tmpnameneedfs:write.
Motivation#
Lus scripts often handle untrusted input or run in sandboxed environments. The permission system provides controlled access to system resources.
Defense in depth#
Scripts explicitly declare their needs, making it clear what capabilities are required:
-- Script header shows requirements
pledge("fs:read")
pledge("network:http")
-- Rest of script uses only these capabilities
Principle of least privilege#
Restrict access to only what’s needed:
-- Only read from specific directory
pledge("fs:read=/var/log/app/*")
-- Script can read logs but nothing else
local logs = fs.list("/var/log/app")
catch fs.list("/etc") -- denied
Host control#
Host applications can pre-reject capabilities before running scripts:
-- Host application
pledge("~exec") -- scripts cannot run commands
pledge("~network") -- scripts cannot access network
pledge("seal") -- lock it down
dofile("untrusted.lus") -- runs with restrictions