v0.1.0

safescript😌

A programming language for AI agents. Provably safe. Immune to supply chain attacks. Ready to eval, no VM required.

Install

Deno
$ deno add jsr:@uri/safescript
npm
$ npx jsr add @uri/safescript
01

Write code

fetch.ss
fetchUser = (id: string, apiKey: string) => {
user = httpRequest({
host: "api.example.com",
method: "GET",
path: "/users",
body: id
headers: { "authorization": apiKey }
})
return user
}

Looks like a normal language. Variables, expressions, function calls. One constraint: when your code calls a host, that host must be a string literal. Not a variable. This is what makes static analysis possible.

02

See the graph

data flow graph
paramidsecretapi-keyhttpRequest"api.example.com"return

Every program compiles to a static directed acyclic graph. No dynamic dispatch, no runtime surprises. We can trace every piece of data from source to sink without executing anything.

03

See everything before it runs

signature
hosts: { "api.example.com" }
dataFlow:
param:id host:api.example.com
param:apiKey host:api.example.com

Computed statically from the source. No execution needed. Every source, every host contacted, every data flow path. You know everything before it runs, so you can run it in-process. No container, no VM, no cold start. Just call a function.

04

Import without fear

main.ss
import fetchUser from "https://example.com/fetch.ss"
perms {
hosts: ["api.example.com"],
dataFlow: { "host:api.example.com": ["param:id", "param:apiKey"] }
}
hash "sha256:9f86d081884c..." // optional, locks a specific version
main = (query: string, apiKey: string) => {
result = fetchUser({ id: query, apiKey: apiKey })
return result
}

Declare what a dependency is allowed to do. The hash locks the source. The perms assert its signature: hosts, secrets, and data flows. New host or secret read? Build fails. Secret starts flowing somewhere new? Build fails. Code changed? Hash fails. Supply chain attacks become build errors.

Isn't a sandbox enough?

The standard answer to running untrusted code is "put it in a sandbox." Every approach has real tradeoffs. Here's what the landscape actually looks like.

ContainersDocker, AWS Lambda, etc.

Cold starts range from 500ms to 10 seconds. A bare Node.js process in Lambda uses ~35MB before your code even loads. Each container gets its own OS process, so context switching eats CPU time that should go to your code. AWS Lambda can only handle one concurrent request per instance, so every new request risks another cold start. You're paying for infrastructure overhead, not computation.

microVMsFirecracker, E2B, Deno Sandbox

Lighter than full containers. Firecracker boots a VM in ~125ms with about 5MB overhead. That's much better than Docker, but you're still spinning up a Linux kernel for every sandbox. E2B and Deno Sandbox both use microVMs under the hood. For AI agents that might run hundreds of code snippets per session, the latency compounds. These are designed for workloads that run for minutes, not for evaluating a small script in microseconds.

V8 IsolatesCloudflare Workers, Dynamic Workers

The best runtime sandbox available today. ~5ms startup, ~3MB memory. Cloudflare calls them 100x faster than containers. The catch: you need their platform to run them. V8 isolates are not something you can trivially embed in your own application. And they're still a runtime boundary. You control what APIs the code can call, but you can't see what the code will dobefore it runs. A compromised dependency inside an isolate can still exfiltrate data through any API you've exposed to it.

Deno Permissions--allow-net, --allow-read, etc.

Process-level flags that restrict I/O at the runtime boundary. Better than Node's "everything is allowed" default. But all code on the same thread shares the same privilege level. If you grant network access to call one API, every dependency can use that access too. eval()and dynamic imports run at full privilege. Deno's own docs recommend using additional sandboxing (VMs, seccomp, cgroups) for truly untrusted code. Permissions tell you what the process can do, not what it will do.

JS Sandboxesisolated-vm, quickjs-emscripten, SandboxJS

In-process sandboxing. Isolated-vm runs V8 isolates inside Node but has had sandbox escape CVEs. QuickJS-emscripten compiles the QuickJS engine to WASM, which gives real memory isolation at ~2MB overhead, but no native async and a limited standard library. SandboxJS-style libraries just wrap eval with property hiding on the global object, which is trivially escapable. The lightweight options aren't hermetic. The hermetic options aren't lightweight.

WebAssemblyWasm sandboxing, WASI

Memory-safe by design: linear memory, no raw pointers, no I/O unless the host provides it. Good security properties. But Wasm modules need explicit host bindings for everything, including basic things like printing or network access. Compilation adds latency. No native async. Debugging is painful. It's an execution format, not a language, so you still need a toolchain to compile code into it. Good for plugin systems, awkward for running arbitrary agent-generated scripts.

safescript's approach

Every approach above tries to restrict what code can do at runtime. Safescript restricts what code can express at all. The compiler proves every security property statically, before any execution. You know exactly which hosts will be contacted, which secrets will be read, and how data flows between them. No sandbox needed because the dangerous operations simply aren't there. Zero overhead. Zero cold start. Zero escape surface. Just call a function in your own process.

ApproachCold startMemoryHermetic
Containers500ms–10s~35MB+partial
microVMs~125ms~5MB+yes
V8 Isolates~5ms~3MBruntime only
Deno perms00no
JS sandboxes<1ms~2MBvaries
Wasm~10ms~2MByes
safescript00static proof
Documentation

https://safescript.uriva.deno.net

A programming language for AI agents. Programs are static DAGs of operations with a closed instruction set, formal data-flow tracking, and resource bounds you can inspect before anything runs. No VM, no container, no sandbox needed.

Install

# Deno
deno add jsr:@uri/safescript

# npm
npx jsr add @uri/safescript

Why this exists

AI agents are getting good enough to write and run code. That's the easy part. The hard part is letting them do it without handing over the keys to the kingdom.

Today, when an agent needs a capability (call an API, transform data, forward a credential), there are two options. Give it a general-purpose language and hope for the best, or restrict it to a handful of hardcoded tools. The first one is a security nightmare. The second one doesn't scale.

The standard fix for the security problem is to throw a sandbox around it. Docker containers, microVMs, Firecracker, E2B, whatever. That works, but now you're paying for it. Every agent execution spins up a container, waits for it to boot, runs a few API calls, and tears it down. You're burning compute and time on infrastructure whose only job is to babysit the code. Cold starts add latency. Orchestration adds complexity. The per-execution cost adds up fast when you're running thousands of agent tasks a day.

safescript takes a different approach. The language is the sandbox. There's nothing to escape from because there's nothing dangerous in the instruction set. No filesystem access, no shell exec, no eval, no dynamic imports. The only things a program can do are the operations explicitly provided by the host. That means you can run safescript programs directly in your application process, in the same runtime as your server. No container spin-up, no VM overhead, no orchestration layer.

It's a real language with variables, expressions, control flow, imports, and a growing set of built-in operations. But it's not Turing-complete, and that's the whole point. Every program compiles down to a static directed acyclic graph of operations. No dynamic dispatch, no infinite loops. The set of things a program can do is fully knowable before it runs.

Every program terminates

safescript is not Turing-complete. That's not a limitation, it's the design.

There are no loops. No recursion. The parser builds a function call graph and rejects cycles at parse time, both direct recursion and mutual recursion. The only iteration constructs are map, filter, and reduce, and they operate on finite arrays. There's no lazy evaluation, no generators, no way to construct unbounded data.

The result is that every safescript program provably halts. You don't need to trust the code, reason about convergence, or set timeouts as a safety net. The language can't express a program that runs forever.

This is the same tradeoff languages like Dhall and SQL (without recursive CTEs) make. You give up the ability to express every computable function. In exchange you get a guarantee that no program will hang your system. For AI agent tasks, that's a trade worth making.

The supply chain problem

Agent skills today look a lot like npm packages did in 2015. Someone publishes a capability. An agent installs it. Nobody reads the source. One day the maintainer pushes an update that exfiltrates inputs to a third-party server, and you find out about it from a blog post.

safescript makes this structurally impossible. Every program has a signature, a complete static description of what it does, computed without executing anything. The signature tells you exactly which hosts it contacts, which environment sources it reads, and how data flows between all of them.

Say an agent skill receives your API key as an input and sends it to api.example.com. That's fine, that's what the skill does. But if an update adds a second HTTP call that forwards that same key to evil.io, the signature changes. The new host shows up. The data flow from param:apiKey to host:evil.io shows up. You can diff signatures between versions and catch this automatically, before the program ever runs.

This isn't a sandbox or a firewall. It's a proof. The language is constrained enough that the analysis is exact, not heuristic.

How signatures work

A signature captures everything a function does without executing it:

{
  name: "createIdentity",
  params: [{ name: "userId", type: "string" }],
  returnType: { status: "number" },
  hosts: ["agentdocs-api.uriva.deno.net"],             // which hosts are contacted
  envReads: [],                                        // timestamp / randomBytes usage
  dataFlow: {
    "host:agentdocs-api...": ["param:userId"],         // userId flows to the API host
    "return": ["host:agentdocs-api..."],               // what data reaches the return value
  },
  sources: ["host:agentdocs-api..."],            // where the return value came from
  memoryBytes: 1002048,                                // worst-case resource bounds
  runtimeMs: 10020,
  diskBytes: 0,
  complexity: "1",                                     // symbolic complexity expression
}

The data flow map is the interesting part. Sources are labeled strings: "param:userId", "host:api.com", "env:timestamp", "env:randomBytes". Sinks are "host:..." and "return". If an input value reaches a host, or a host's response reaches another host, it shows up explicitly in the map.

Resource bounds accumulate from every operation in the program. Each op declares its own memory, runtime, and disk cost. The signature sums them. For branches (ternary, if/else), it conservatively takes the union of sources and the sum of resources from both sides.

Complexity inference

Signatures now include a symbolic complexity expression derived automatically from the program structure. The analyzer tracks the size of every value—string length for strings, element count for arrays—and composes them into a precise Big-O style formula using parameter names and host labels as variables.

{
  // ... other signature fields
  complexity: "param:items + host:api.example.com"
}

Examples of what the analyzer produces:

  • 1 — constant work (e.g., timestamp(), httpRequest)
  • param:items — linear in array length (e.g., map(double, items))
  • param:text — linear in string length (e.g., sha256({ data: text }))
  • param:matrix * param:matrix — quadratic from nested map (when inner function complexity depends on the outer array)
  • host:api.example.com — linear in the response body size

The expression is a sum of monomials. Each term is a coefficient times a product of size variables. Variables are named after the source they measure:

  • param:<name> — size of parameter <name> (string length or array length)
  • host:<hostname> — size of the response body from <hostname>

For map/filter/reduce, the complexity of the inner function is multiplied by the array length. Currently the element size passed to the inner function is treated as constant (1), so map(sha256, strings) where strings: string[] is inferred as O(n) in the array length rather than O(total_chars). This is conservative for most agent skills and may be refined in future versions.

Complexity can later be used as a policy bound: a permission assertion can require that an imported function stay within O(n) or exclude terms above a certain degree.

Syntax

safescript looks like a subset of JavaScript but it's actually a DAG description language. There's no runtime object model, no prototype chain, no closures. Just operations and data flow.

Functions

Files contain one or more named functions. Each takes typed parameters and returns a value:

greet = (name: string, times: number): string => {
  msg = stringConcat({ parts: ["hello, ", name] });
  return msg;
};

The return type annotation (: string after the parameters) is optional but recommended.

Types

Primitives (string, number, boolean), objects ({ name: string, age: number }), and arrays (string[], { id: number }[]). Nested combinations work: { users: { name: string }[] }.

Operations

All computation happens through op calls. Ops take a single object argument with named fields:

hash = sha256({ data: apiKey });
r = httpRequest({
  host: "api.example.com",
  method: "POST",
  path: "/data",
  body: hash,
});

Some ops have static fields that must be string/number/boolean literals, not variables. httpRequest requires host to be a literal. This is enforced at parse time. It's what makes the signature system work: the set of hosts is always statically known.

Void calls (ops called for side effects without capturing the return value) work too:

httpRequest({ host: "audit.example.com", method: "POST", path: "/events", body: data });

Expressions

Arithmetic (+, -, *, /, %), comparisons (==, !=, <, >, <=, >=), string concatenation (+), unary negation (-x), ternary (cond ? a : b), dot access (obj.field.nested), array literals ([a, b, c]), object literals ({ key: val, shorthand }), and parenthesized grouping ((a + b) * c).

Ternary is right-associative, so a ? b : c ? d : e means a ? b : (c ? d : e). Operator precedence follows the standard math/C convention.

Shorthand

Object fields support JS-style shorthand. { body } is sugar for { body: body }. String keys are supported for non-identifier names: { "x-signature": sig }.

Comments

// line comments only

Control flow

Statement-level if/else with Go-like syntax (no parens around condition, braces required):

if x > threshold {
  result = httpRequest({ host: "primary-api.com", method: "POST", path: "/data", body: payload })
} else {
  result = httpRequest({ host: "fallback-api.com", method: "POST", path: "/data", body: payload })
}

else is optional. An if without else is valid for conditional side effects:

if shouldCache {
  httpRequest({ host: "cache.example.com", method: "POST", path: "/cache", body: data })
}

There's no else if keyword. Nest manually:

if x > 0 {
  label = "positive"
} else {
  if x == 0 {
    label = "zero"
  } else {
    label = "negative"
  }
}

At runtime, only the taken branch executes. The other branch's ops are completely skipped. For static analysis, both branches are conservatively analyzed: sources are unioned and resource bounds are summed.

Map, filter, reduce

safescript has built-in map, filter, and reduce as reserved words. They take a named function reference (not a lambda) and an array:

double = (x: number): number => {
  return x * 2;
};

isPositive = (x: number): boolean => {
  return x > 0;
};

sum = (acc: number, x: number): number => {
  return acc + x;
};

process = (numbers: number[]): number => {
  doubled = map(double, numbers);
  positive = filter(isPositive, doubled);
  total = reduce(sum, 0, positive);
  return total;
};

The function comes first, the array comes last. For reduce, the initial accumulator value goes in the middle: reduce(fn, initial, array).

Function arity is enforced. map and filter require a function that takes exactly one parameter. reduce requires a function that takes exactly two (accumulator, element).

map and filter execute in parallel via Promise.all. This matters when your mapped function does network calls. reduce executes sequentially since each step depends on the previous accumulator.

These work with both local functions and imported functions. The function name must refer to a function defined in the same program or imported at the top of the file.

Imports

safescript programs can import functions from other safescript programs. Imports go at the top of the file, before any function definitions:

import add from "./math.ss" perms {} hash "sha256:abc123..."

sum = (a: number, b: number): number => {
  result = add({ x: a, y: b })
  return result
}

The imported function becomes available as a regular op in the local program. You call it the same way you call any built-in: add({ x: a, y: b }).

Aliasing. If the imported name conflicts with something local, use as:

import add as mathAdd from "./math.ss" perms {} hash "sha256:abc123..."

Hash verification. The hash field is a SHA-256 hash of the dependency's normalized form. Normalization strips comments, normalizes whitespace, and alpha-renames internal variables (parameters become _p0, _p1; locals become _v0, _v1) while preserving function names, op names, and string literals. This means cosmetic changes (renaming a variable, reformatting) don't break the hash. Semantic changes do. If the dep's content doesn't match the declared hash, the build fails.

To get the hash of a program:

import { hashProgram } from "safescript";

const hash = await hashProgram(sourceCode);
// "sha256:e3b0c44298fc1c149afbf4c8996fb924..."

Permission assertions. The perms block declares exactly what the imported function (and all its transitive dependencies) can do. The fields match the signature: hosts, envReads, and dataFlow. The first two are arrays of string literals. dataFlow is an object mapping sink labels to arrays of source labels. Missing fields mean empty sets, except dataFlow which is optional: omit it to skip the data flow check.

import fetchUser from "https://example.com/user.ss" perms {
  hosts: ["api.example.com"],
  dataFlow: {
    "host:api.example.com": ["param:userId"],
    "return": ["host:api.example.com"]
  }
} hash "sha256:..."

This is not a permissions grant, it's an assertion. The resolver computes the actual transitive signature of the imported function and checks that it exactly matches the declared perms. If the dep secretly starts reading a new host, using a new env source, or routing data somewhere new, the assertion fails and the build breaks. You must update the perms declaration to acknowledge the change.

A pure dependency (no hosts, no env reads) uses empty braces:

import add from "./math.ss" perms {} hash "sha256:..."

Transitive composition. Dependencies can have their own imports. The resolver processes the entire transitive chain. Each dependency's perms are verified against its full transitive signature. Circular dependencies are impossible by construction since each import must declare a hash, and you can't hash something that references itself.

Diamond dependencies. If two imports share a common transitive dependency (same hash), it's resolved once and cached. No duplication.

Built-in operations

I/O

OpStatic fieldsDescription
httpRequest({ host, method, path, headers?, body? })hostHTTPS request to declared host

Pure

OpDescription
jsonParse({ text })Parse JSON string to value
jsonStringify({ value })Serialize value to JSON string
stringConcat({ parts })Concatenate an array of strings
base64urlEncode({ data })Base64url encode
base64urlDecode({ data })Base64url decode
pick({ object, keys })Pick keys from an object
merge({ objects })Shallow merge objects
sha256({ data })SHA-256 hash

Crypto

OpDescription
generateEd25519KeyPair()Generate Ed25519 signing keypair
generateX25519KeyPair()Generate X25519 key agreement keypair
ed25519Sign({ data, privateKey })Sign data with Ed25519
aesGenerateKey()Generate AES-GCM key
aesEncrypt({ key, plaintext })AES-GCM encrypt
aesDecrypt({ key, ciphertext })AES-GCM decrypt
x25519DeriveKey({ privateKey, publicKey })Derive shared secret via X25519
importIdentity({ exported })Import a serialized identity
exportIdentity({ keys })Export an identity to serializable form

Sources

OpDescription
timestamp()Current Unix timestamp (tagged as non-deterministic)
randomBytes({ length })Cryptographic random bytes (tagged as non-deterministic)

Architecture

safescript has two layers.

The op layer is a TypeScript library for defining and composing typed operations (DagOp objects). Each op has a Zod input/output schema, a manifest declaring its resource costs and tags, and a run function. This layer includes compose() for building DAGs programmatically and execute() for running them. It's usable on its own if you want to build pipelines in TypeScript.

The language layer sits on top. It has a lexer, parser, interpreter, and signature analyzer. The parser produces an AST, the interpreter walks it and calls into the op registry, and the signature analyzer walks it without executing to produce a Signature. Programs are .safescript files with the custom syntax described above.

The op registry bridges the two layers. It maps string op names (as they appear in safescript source) to OpEntry objects that know which fields are static and how to create the underlying DagOp. The builtin registry covers all the ops listed above. Custom registries can be passed to both interpret() and computeSignature().

The execution context (ExecutionContext) provides the external world: fetch. It's injected via AsyncLocalStorage so ops access it through getContext() without passing it as an argument.

Usage

import {
  builtinRegistry,
  computeSignature,
  interpret,
  parse,
  tokenize,
} from "safescript";

const source = `
  fetchData = (userId: string) => {
    body = jsonStringify({ value: { userId } })
    result = httpRequest({
      host: "api.example.com",
      method: "POST",
      path: "/lookup",
      body
    })
    return result
  }
`;

// Parse
const program = parse(tokenize(source));

// Static analysis (no execution, no context needed)
const sig = computeSignature(program, "fetchData");
console.log(sig.hosts); // Set { "api.example.com" }
console.log(sig.dataFlow); // param:userId flows to host:api.example.com, etc.

// Execute (requires context)
const result = await interpret(program, "fetchData", { userId: "alice" }, {
  fetch: globalThis.fetch,
});

Transpilers

safescript programs can be transpiled to runnable TypeScript or Python. The transpilers emit self-contained code with a full runtime preamble that implements all built-in operations. No safescript runtime dependency is needed to run the output.

TypeScript

import { parse, tokenize, toTypescript } from "safescript";

const source = `
  greet = (name: string): string => {
    msg = stringConcat({ parts: ["hello, ", name] })
    return msg
  }
`;

const program = parse(tokenize(source));
const tsCode = toTypescript(program);
// or: toTypescript(program, "greet") to emit only one function

The output uses the Web Crypto API for all crypto operations. Each function takes its parameters as a Record<string, any> plus an ExecutionContext for IO ops (httpRequest). Functions that only use pure ops still require the context parameter for a uniform interface.

Python

import { parse, tokenize, toPython } from "safescript";

const program = parse(tokenize(source));
const pyCode = toPython(program);
// or: toPython(program, "greet") to emit only one function

The output uses asyncio for async execution, aiohttp for HTTP requests, and the cryptography package for crypto operations. Functions use Python keyword-only arguments (*, param1, param2, _ctx). Booleans emit as True/False, objects as dicts.

Both transpilers support the functionName parameter to emit a single function. If omitted, all functions in the program are emitted.

What this doesn't do

safescript is not a general-purpose language. You can't write a web server in it or sort a list. There's no recursion, no unbounded loops, no dynamic dispatch. It's a language for writing agent skills that interact with APIs and inputs in a way that can be formally reasoned about.

If you need Turing-completeness, use a real language and accept the security tradeoffs. If you need provable safety with useful capabilities, this is the trade you make.