Skip to content

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.

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.

  1. Compiler tagging. When the compiler encounters an import statement, it consults the static capability_tags table 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.

  2. Linker union. At link time, the linker computes the transitive union of all blobs’ required_permissions and stores the result on LinkedProgram.total_required_permissions:

    total_required_permissions = blobs.fold(PermissionSet::pure(), |acc, blob| {
    acc.union(&blob.required_permissions)
    })
  3. Load-time gate. The host calls load_program_with_permissions(program, granted). This links the program, then checks that total_required_permissions is a subset of granted. If any permissions are missing, the load fails immediately with a PermissionError listing 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
  4. 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.

Pure-computation modules (json, crypto, testing, regex, log, math) require no permissions. I/O modules are tagged per-function:

ModuleFunctionRequired Permission
ioopen, read_fileFsRead
iowrite_fileFsWrite
iotcp_connectNetConnect
iolistenNetListen
iospawn, execProcess
fileread_text, read_lines, read_bytesFsRead
filewrite_text, write_bytes, appendFsWrite
httpget, post, put, deleteNetConnect
envget, has, all, args, cwdEnv
timemillisTime

Functions not listed in a known module (including time.now) require no permissions.

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.

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 calls check_permission(ctx, Permission) at its entry point. If the permission is not in the set, the function returns Err("Permission denied: <description> (<name>)").

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.

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)

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.

// 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.

Two constructors cover common cases:

ConstructorInstructionsMemoryWall TimeOutput
ResourceLimits.unlimited()NoneNoneNoneNone
ResourceLimits.sandboxed()10,000,000256 MB30 seconds1 MB

ResourceLimits.sandboxed() is designed for untrusted code execution — it allows meaningful computation while preventing denial-of-service.

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 checks max_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. Checks max_memory_bytes.

  • record_output(bytes) — called when the program writes to stdout/stderr. Checks max_output_bytes.

When a limit is exceeded, the VM halts with a ResourceLimitExceeded error. The error enum carries the limit and actual usage for diagnostics:

VariantFieldsMessage
InstructionLimitlimit, executedInstruction limit exceeded: {executed} >= {limit}
MemoryLimitlimit, allocatedMemory limit exceeded: {allocated} bytes >= {limit} bytes
WallTimeLimitlimit, elapsedWall time limit exceeded: {elapsed} >= {limit}
OutputLimitlimit, writtenOutput limit exceeded: {written} bytes >= {limit} bytes

Shape defines 16 permissions organized into four categories:

PermissionMachine NameDescription
FsReadfs.readRead files and directories
FsWritefs.writeWrite, create, and delete files and directories
FsScopedfs.scopedFilesystem access scoped to specific paths
PermissionMachine NameDescription
NetConnectnet.connectOpen outbound network connections
NetListennet.listenListen for inbound network connections
NetScopednet.scopedNetwork access scoped to specific hosts/ports
PermissionMachine NameDescription
Processsys.processSpawn child processes
Envsys.envRead environment variables
Timesys.timeAccess wall-clock time
Randomsys.randomAccess random number generation
PermissionMachine NameDescription
Vfssandbox.vfsOperate against a virtual filesystem
Deterministicsandbox.deterministicRun in a deterministic runtime (fixed time, seeded RNG)
Capturesandbox.captureOutput is captured for inspection
MemLimitedsandbox.mem_limitedMemory usage is limited to a configured ceiling
TimeLimitedsandbox.time_limitedExecution time is capped
OutputLimitedsandbox.output_limitedOutput 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).

A PermissionSet is a deterministic set backed by a BTreeSet, so iteration order is stable and serialization is reproducible.

ConstructorContents
PermissionSet.pure()Empty set — pure computation, no capabilities
PermissionSet.readonly()FsRead + Env + Time
PermissionSet.full()All 16 permissions
OperationDescription
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

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 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
}

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)
// 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)
// 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)

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 permissions
let 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::InsufficientPermissions
let 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:

analysis.shape
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.

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.

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: ... }

For maximum security, use all three tiers together:

// 1. Compile-time: only allow filesystem read + env
let 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)