Acquis 10 - Permissions

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

PermissionDescription
fsAll filesystem operations
fs:readReading files and directories
fs:writeCreating, modifying, deleting files
loadLoading code at runtime (load, loadfile, dofile, require)
execRunning external commands (os.execute, io.popen)
networkAll network operations
network:tcpTCP socket connections
network:udpUDP socket operations
network:httpHTTP/HTTPS requests

The loadfile, dofile, and require functions require both fs:read (to read the file) and load (to execute the code). The load function requires only load.


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