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) => {
key = readSecret("api-key")
user = httpRequest({
host: "api.example.com",
method: "GET",
path: "/users",
headers: key,
body: id
})
return user
}

Looks like a normal language. Variables, expressions, function calls. One constraint: when your code reads a secret or calls a host, those names must be string literals. Not variables. 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
secretsRead: { "api-key" }
hosts: { "api.example.com" }
dataFlow:
param:id host:api.example.com
secret:api-key host:api.example.com ⚠️ secret exposed to host

Computed statically from the source. No execution needed. Every secret read, 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"],
secretsRead: ["api-key"],
dataFlow: { "host:api.example.com": ["param:id", "secret:api-key"] }
}
hash "sha256:9f86d081884c..." // optional, locks a specific version
main = (query: string) => {
result = fetchUser({ id: query })
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.

Documentation

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, store a credential, read a secret), 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 secrets 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 secrets it reads, which secrets it writes, which hosts it contacts, and how data flows between all of them.

Say an agent skill reads your API key from a secret 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 secret:api-key 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" },
  secretsRead: ["agentdocs-identity"],                // which secrets are accessed
  secretsWritten: ["agentdocs-identity"],              // which secrets are modified
  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
    "secret:agentdocs...": ["..."],                    // what data reaches each secret
    "return": ["host:agentdocs-api..."],               // what data reaches the return value
  },
  returnSources: ["host:agentdocs-api..."],            // where the return value came from
  memoryBytes: 1002048,                                // worst-case resource bounds
  runtimeMs: 10020,
  diskBytes: 0,
}

The data flow map is the interesting part. Sources are labeled strings: "param:userId", "secret:token", "host:api.com", "env:timestamp", "env:randomBytes". Sinks are "host:...", "secret:...", and "return". If a secret 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.

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:

secret = readSecret({ name: "api-key" })
hash = sha256({ data: secret })
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. readSecret requires name to be a literal. httpRequest requires host to be a literal. This is enforced at parse time. It's what makes the signature system work: the set of secrets and hosts is always statically known.

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

writeSecret({ name: "cache", value: 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 {
  writeSecret({ name: "cache", value: 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 five fields match the signature: secretsRead, secretsWritten, hosts, envReads, and dataFlow. The first four 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 {
  secretsRead: ["api-token"],
  hosts: ["api.example.com"],
  dataFlow: {
    "host:api.example.com": ["param:userId", "secret:api-token"],
    "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 secret, contacting a new host, 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 secrets, 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
readSecret({ name })nameRead a named secret
writeSecret({ name, value })nameWrite a named secret
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: readSecret, writeSecret, and fetch. It's injected via AsyncLocalStorage so ops access it through getContext() without passing it as an argument. This means the host environment controls what secrets are available and what network access looks like.

Usage

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

const source = `
  fetchData = (userId: string) => {
    token = readSecret({ name: "api-token" })
    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.secretsRead);  // Set { "api-token" }
console.log(sig.dataFlow);     // param:userId flows to host:api.example.com, etc.

// Execute (requires context)
const result = await interpret(program, "fetchData", { userId: "alice" }, {
  readSecret: (name) => getSecretFromVault(name),
  writeSecret: (name, value) => saveSecretToVault(name, value),
  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 { tokenize, parse, 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 (readSecret, writeSecret, httpRequest). Functions that only use pure ops still require the context parameter for a uniform interface.

Python

import { tokenize, parse, 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 secrets 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.