Security & Permissions
Shape enforces a three-tier security model that controls what a program can do, where it can reach, and how many resources it may consume. The tiers are complementary: compile-time capability checking catches unauthorized operations before the program runs, runtime permission gating blocks individual stdlib calls that exceed the granted set, and runtime sandboxing caps resource consumption to prevent runaway execution.
Tier 1: Compile-Time Capability Checking
Section titled “Tier 1: Compile-Time Capability Checking”Every FunctionBlob carries a required_permissions field — a PermissionSet
derived from the stdlib calls the function makes. Because permissions are part
of the blob’s content hash, two functions with different permission requirements
always produce different hashes, even if their logic is otherwise identical.
How It Works
Section titled “How It Works”-
Compiler tagging. When the compiler encounters an
importstatement, it consults the staticcapability_tagstable to determine which permissions each imported function requires. For named imports, only the specific functions’ permissions are added. For namespace imports (from std::core::io use { open, write }), the entire module’s permission envelope is checked. -
Linker union. At link time, the linker computes the transitive union of all blobs’
required_permissionsand stores the result onLinkedProgram.total_required_permissions:total_required_permissions = blobs.fold(PermissionSet::pure(), |acc, blob| {acc.union(&blob.required_permissions)}) -
Load-time gate. The host calls
load_program_with_permissions(program, granted). This links the program, then checks thattotal_required_permissionsis a subset ofgranted. If any permissions are missing, the load fails immediately with aPermissionErrorlisting exactly which permissions are missing:// Conceptual flow inside the VM://// let linked = link(program)// if !linked.total_required_permissions.is_subset(granted) {// let missing = linked.total_required_permissions.difference(granted)// return Err(PermissionError::InsufficientPermissions { required, granted, missing })// }// vm.load(linked) // proceed normally -
Zero runtime cost. The check happens once at load time. After that, the VM dispatch loop runs at full speed with no per-instruction permission overhead.
Capability Tags
Section titled “Capability Tags”Pure-computation modules (json, crypto, testing, regex, log, math)
require no permissions. I/O modules are tagged per-function:
| Module | Function | Required Permission |
|---|---|---|
io | open, read_file | FsRead |
io | write_file | FsWrite |
io | tcp_connect | NetConnect |
io | listen | NetListen |
io | spawn, exec | Process |
file | read_text, read_lines, read_bytes | FsRead |
file | write_text, write_bytes, append | FsWrite |
http | get, post, put, delete | NetConnect |
env | get, has, all, args, cwd | Env |
time | millis | Time |
Functions not listed in a known module (including time.now) require no
permissions.
Tier 2: Runtime Permission Gating
Section titled “Tier 2: Runtime Permission Gating”Even after a program passes compile-time checking, individual stdlib calls are
guarded at runtime. This provides defense-in-depth: a program loaded with
PermissionSet::full() at compile time can still be narrowed at runtime by
setting granted_permissions on the ModuleContext.
ModuleContext
Section titled “ModuleContext”The VM constructs a ModuleContext before each module function dispatch. Two
fields control runtime gating:
// Conceptual structure:type ModuleContext { // ... schema registry, callable invoker, etc. ...
granted_permissions: Option<PermissionSet>, scope_constraints: Option<ScopeConstraints>,}granted_permissions: None— all operations are allowed. This is the default for backwards compatibility with code that predates the permission system.granted_permissions: Some(set)— every stdlib I/O function callscheck_permission(ctx, Permission)at its entry point. If the permission is not in the set, the function returnsErr("Permission denied: <description> (<name>)").
check_permission
Section titled “check_permission”The guard is a single function called at the top of every stdlib I/O operation:
// Pseudocode for the check_permission guard://// fn check_permission(ctx, permission) -> Result<(), string> {// match ctx.granted_permissions {// None => Ok(()), // backwards compatible: allow all// Some(granted) => {// if granted.contains(permission) {// Ok(())// } else {// Err(f"Permission denied: {permission.description} ({permission.name})")// }// }// }// }For example, io::open("data.csv", "r") calls check_permission(ctx, FsRead). If the context grants FsRead, the call proceeds. If not, the
caller receives a Result::Err with a descriptive message.
The cost is one set lookup per stdlib call — approximately 5 nanoseconds on modern hardware. This is negligible compared to the I/O operation itself.
Per-Function Gating Examples
Section titled “Per-Function Gating Examples”File operations check the mode to determine which permission is required:
// io::open checks the mode argument:// "r" → check_permission(ctx, FsRead)// "w" → check_permission(ctx, FsWrite)// "a" → check_permission(ctx, FsWrite)// "rw" → check_permission(ctx, FsRead) AND check_permission(ctx, FsWrite)Network operations check at the top of each function:
// io::tcp_connect(addr) → check_permission(ctx, NetConnect)// io::tcp_listen(addr) → check_permission(ctx, NetListen)// io::tcp_accept(l) → check_permission(ctx, NetListen)Tier 3: Runtime Sandboxing
Section titled “Tier 3: Runtime Sandboxing”The third tier limits how much of the host’s resources a program may consume. This is independent of the permission model — a program can have full permissions but still be sandboxed to prevent runaway computation.
ResourceLimits
Section titled “ResourceLimits”// Conceptual structure:type ResourceLimits { max_instructions: Option<int>, // default: None (unlimited) max_memory_bytes: Option<int>, // default: None (unlimited) max_wall_time: Option<Duration>, // default: None (unlimited) max_output_bytes: Option<int>, // default: None (unlimited)}Each field is optional. None means unlimited — no cap is enforced for that
resource.
Presets
Section titled “Presets”Two constructors cover common cases:
| Constructor | Instructions | Memory | Wall Time | Output |
|---|---|---|---|---|
ResourceLimits.unlimited() | None | None | None | None |
ResourceLimits.sandboxed() | 10,000,000 | 256 MB | 30 seconds | 1 MB |
ResourceLimits.sandboxed() is designed for untrusted code execution — it
allows meaningful computation while preventing denial-of-service.
ResourceUsage
Section titled “ResourceUsage”A ResourceUsage tracker is initialized from a ResourceLimits and called
from the VM dispatch loop:
-
tick_instruction()— called once per instruction. Increments the instruction counter and checksmax_instructions. To avoid calling the system clock on every instruction, wall-time is checked on an amortized schedule: every 1024 instructions. -
record_allocation(bytes)— called when the VM allocates heap memory. Checksmax_memory_bytes. -
record_output(bytes)— called when the program writes to stdout/stderr. Checksmax_output_bytes.
ResourceLimitExceeded
Section titled “ResourceLimitExceeded”When a limit is exceeded, the VM halts with a ResourceLimitExceeded error.
The error enum carries the limit and actual usage for diagnostics:
| Variant | Fields | Message |
|---|---|---|
InstructionLimit | limit, executed | Instruction limit exceeded: {executed} >= {limit} |
MemoryLimit | limit, allocated | Memory limit exceeded: {allocated} bytes >= {limit} bytes |
WallTimeLimit | limit, elapsed | Wall time limit exceeded: {elapsed} >= {limit} |
OutputLimit | limit, written | Output limit exceeded: {written} bytes >= {limit} bytes |
Permission Enum
Section titled “Permission Enum”Shape defines 16 permissions organized into four categories:
Filesystem
Section titled “Filesystem”| Permission | Machine Name | Description |
|---|---|---|
FsRead | fs.read | Read files and directories |
FsWrite | fs.write | Write, create, and delete files and directories |
FsScoped | fs.scoped | Filesystem access scoped to specific paths |
Network
Section titled “Network”| Permission | Machine Name | Description |
|---|---|---|
NetConnect | net.connect | Open outbound network connections |
NetListen | net.listen | Listen for inbound network connections |
NetScoped | net.scoped | Network access scoped to specific hosts/ports |
System
Section titled “System”| Permission | Machine Name | Description |
|---|---|---|
Process | sys.process | Spawn child processes |
Env | sys.env | Read environment variables |
Time | sys.time | Access wall-clock time |
Random | sys.random | Access random number generation |
Sandbox
Section titled “Sandbox”| Permission | Machine Name | Description |
|---|---|---|
Vfs | sandbox.vfs | Operate against a virtual filesystem |
Deterministic | sandbox.deterministic | Run in a deterministic runtime (fixed time, seeded RNG) |
Capture | sandbox.capture | Output is captured for inspection |
MemLimited | sandbox.mem_limited | Memory usage is limited to a configured ceiling |
TimeLimited | sandbox.time_limited | Execution time is capped |
OutputLimited | sandbox.output_limited | Output volume is capped |
Every permission has a name() (dotted machine name, stable across versions),
a description() (human-readable, suitable for permission prompts), and a
category() (for grouping in UIs).
PermissionSet API
Section titled “PermissionSet API”A PermissionSet is a deterministic set backed by a BTreeSet, so iteration
order is stable and serialization is reproducible.
Constructors
Section titled “Constructors”| Constructor | Contents |
|---|---|
PermissionSet.pure() | Empty set — pure computation, no capabilities |
PermissionSet.readonly() | FsRead + Env + Time |
PermissionSet.full() | All 16 permissions |
Set Operations
Section titled “Set Operations”| Operation | Description |
|---|---|
a.union(b) | All permissions from both sets |
a.intersection(b) | Only permissions in both sets |
a.difference(b) | Permissions in a but not in b |
a.is_subset(b) | True if every permission in a is also in b |
a.is_superset(b) | True if every permission in b is also in a |
a.contains(perm) | True if the specific permission is in the set |
a.insert(perm) | Add a permission (returns whether it was new) |
a.remove(perm) | Remove a permission (returns whether it was present) |
a.is_empty() | True when the set has no permissions |
a.len() | Number of permissions in the set |
a.by_category() | Group permissions into a map by PermissionCategory |
Display
Section titled “Display”Formatting a PermissionSet produces a brace-delimited, comma-separated list
of machine names:
// PermissionSet containing FsRead and Env displays as:// {fs.read, sys.env}ScopeConstraints
Section titled “ScopeConstraints”ScopeConstraints narrows a permission grant to specific targets. They are
attached to a PermissionGrant (a permission + optional constraints pair) and
stored on the ModuleContext as scope_constraints.
// Conceptual structure:type ScopeConstraints { allowed_paths: Array<string>, // glob patterns for FsScoped allowed_hosts: Array<string>, // host:port patterns for NetScoped max_memory_bytes: Option<int>, // ceiling for MemLimited max_time_ms: Option<int>, // ceiling for TimeLimited max_output_bytes: Option<int>, // ceiling for OutputLimited}PermissionGrant
Section titled “PermissionGrant”A PermissionGrant pairs a permission with optional scope constraints:
// Unconstrained grant (full FsRead, no path restrictions):let grant = PermissionGrant.unconstrained(FsRead)
// Scoped grant (FsScoped limited to specific directories):let constraints = ScopeConstraints { allowed_paths: ["/tmp/*", "/data/**"], allowed_hosts: [], max_memory_bytes: None, max_time_ms: None, max_output_bytes: None,}let grant = PermissionGrant.scoped(FsScoped, constraints)Example: Network Scoping
Section titled “Example: Network Scoping”// Allow network connections only to specific hosts:let constraints = ScopeConstraints { allowed_paths: [], allowed_hosts: ["api.example.com:443", "db.internal:5432"], max_memory_bytes: None, max_time_ms: None, max_output_bytes: None,}let grant = PermissionGrant.scoped(NetScoped, constraints)Example: Resource Scoping
Section titled “Example: Resource Scoping”// Limit memory and execution time:let constraints = ScopeConstraints { allowed_paths: [], allowed_hosts: [], max_memory_bytes: 64 * 1024 * 1024, // 64 MB max_time_ms: 5000, // 5 seconds max_output_bytes: 1024 * 1024, // 1 MB}let grant = PermissionGrant.scoped(MemLimited, constraints)Practical Examples
Section titled “Practical Examples”1. Loading a Program with Restricted Permissions
Section titled “1. Loading a Program with Restricted Permissions”Grant only filesystem read and environment access. The program will fail to load if it requires anything else:
from std::core::state use { capture, serialize }
// Compile the program (produces content-addressed blobs)let program = compile("analysis.shape")
// Grant only read-only permissionslet granted = PermissionSet.readonly() // FsRead + Env + Time
// Load with permission checking// If the program calls io::write_file, io::tcp_connect, etc.,// this will fail with PermissionError::InsufficientPermissionslet result = vm.load_program_with_permissions(program, granted)
match result { Ok(()) => { print("Program loaded successfully") vm.run() }, Err(e) => { print(f"Load denied: {e}") // e.g. "program requires permissions not granted: fs.write, net.connect" }}2. Compile-Time Checking Catches Unauthorized I/O
Section titled “2. Compile-Time Checking Catches Unauthorized I/O”When the compiler encounters an import of a function that requires permissions not in the blob’s granted set, it reports a compile-time error:
from std::core::io use { open, read_to_string, write_file }
fn process_data(path: string) -> string { let f = io::open(path, "r") // requires FsRead ✓ let data = io::read_to_string(f) io::write_file("out.csv", data) // requires FsWrite ✗ data}
// Compiling with PermissionSet.readonly():// ERROR: import "io::write_file" requires permission fs.write,// which is not in the granted set {fs.read, sys.env, sys.time}The compiler tags the FunctionBlob with required_permissions = {FsRead, FsWrite}. When the linker computes
total_required_permissions = {FsRead, FsWrite} and the host grants only
{FsRead, Env, Time}, the is_subset check fails and the missing permission
FsWrite is reported.
3. Runtime Gating Blocks a Network Call
Section titled “3. Runtime Gating Blocks a Network Call”Even if a program passes compile-time checks (perhaps loaded with full permissions), runtime gating can enforce a narrower policy:
from std::core::io use { tcp_connect, tcp_write, tcp_read }
fn fetch_prices(url: string) -> string { // At runtime, this calls check_permission(ctx, NetConnect) let conn = io::tcp_connect(url)
// If ctx.granted_permissions does not contain NetConnect: // → Err("Permission denied: Open outbound network connections (net.connect)")
io::tcp_write(conn, "GET / HTTP/1.0\r\n\r\n") io::tcp_read(conn)}The host controls this by setting granted_permissions on the ModuleContext
before execution. A program loaded with PermissionSet.full() at compile time
can be restricted at runtime to PermissionSet.readonly(), and any network
calls will fail with a descriptive error.
4. Sandboxing Limits Runaway Computation
Section titled “4. Sandboxing Limits Runaway Computation”Use ResourceLimits.sandboxed() to cap resource consumption for untrusted
code:
// Host-side setup (conceptual):let limits = ResourceLimits.sandboxed()// limits.max_instructions = 10,000,000// limits.max_memory_bytes = 268,435,456 (256 MB)// limits.max_wall_time = 30 seconds// limits.max_output_bytes = 1,048,576 (1 MB)
let usage = ResourceUsage.new(limits)usage.start()
// During VM execution, the dispatch loop calls:// usage.tick_instruction() → every instruction// usage.record_allocation(n) → every heap allocation// usage.record_output(n) → every print/write
// If the program enters an infinite loop:// After 10,000,000 instructions:// → ResourceLimitExceeded::InstructionLimit { limit: 10000000, executed: 10000000 }
// If the program allocates too much memory:// → ResourceLimitExceeded::MemoryLimit { limit: 268435456, allocated: ... }
// If execution takes too long:// (Checked every 1024 instructions to amortize clock reads)// → ResourceLimitExceeded::WallTimeLimit { limit: 30s, elapsed: ... }Combining All Three Tiers
Section titled “Combining All Three Tiers”For maximum security, use all three tiers together:
// 1. Compile-time: only allow filesystem read + envlet granted = PermissionSet.readonly()
// 2. Load with permission checking (Tier 1)let result = vm.load_program_with_permissions(program, granted)
// 3. Set runtime permissions on ModuleContext (Tier 2)// ctx.granted_permissions = Some(granted)// ctx.scope_constraints = Some(ScopeConstraints {// allowed_paths: ["/data/readonly/**"],// ...// })
// 4. Apply resource limits (Tier 3)let limits = ResourceLimits.sandboxed()let usage = ResourceUsage.new(limits)
// Now the program:// - Cannot link if it imports write/network functions (Tier 1)// - Cannot call any I/O not in the granted set at runtime (Tier 2)// - Cannot exceed 10M instructions, 256MB memory, 30s wall time (Tier 3)See Also
Section titled “See Also”- Content-Addressed Bytecode — how
FunctionBlobcontent hashes incorporate permissions - Standard Library: I/O — the I/O functions gated by the permission system
- Standard Library: State — state capture, serialization, and distributed primitives