Edge Python
Esc

Go to

Commands

Sign In

Sign in to publish packages and pin them by sha256.


By continuing, you accept our Terms and Conditions.

@
Palette

System

Edge Python reaches outside its sandbox through four system modules, fs for the project files, net for the network, time for the clock and secret for the values a host keeps for the program. They ship inside Edge Python and need no imports entry, but a package can only import them when the root edge.json grants it.

import fs
import net
import time
import secret
python

Permissions

The root edge.json grants each package it imports, and each package passes on to the ones it imports. A permissions section maps each holder to a list of entries, each module:scope.

{
  "imports": { "analytics": "1.2.0" },
  "permissions": {
    "main": ["net:api.example.com", "time:wall", "time:zone"],
    "analytics": ["net:api.telemetry.com", "time:monotonic"]
  }
}
json
HolderWho holds its entries
mainThe code of the package itself, for the root every module under the root edge.json
allmain and every package it imports
An import keyThe package that key imports
evalNobody, it caps what a bundle may grant in an eval group
EntryAllows
net:<host>request and connect to that exact host, on any port and path. The host is a lowercase name or an IPv4 written as a.b.c.d, with no scheme or port. IPv6 is not supported, since the policy of a browser frame cannot name one
net:<host>/<prefix>The same host, but only at <prefix> and the paths under it, so net:api.example.com/v2 reaches /v2 and /v2/items and no other path. A path counts as under it only if it stays there however a server reads it, decoded once or twice, a backslash taken for a slash and a ; parameter dropped, so /v2/..%2fadmin and /v2/..;/admin are refused while /v2/group%2Fproject reaches
time:wallnow()
time:monotonicnow('monotonic')
time:zonezone()
secret:<NAME>read('<NAME>'), that one value and no other. The name is uppercase letters, digits and underscores, and never starts with a digit
fs:./<folder>read and list under that folder of the project, written from the root edge.json, so fs:./shop reaches shop/Gemfile and never shopping/
fs:.read and list anywhere in the project

A package is what an import key brings in, the nearest edge.json above its files. It holds what its importer writes under that key joined with what the importer writes for all, and an importer other than the root passes on only what it holds itself. The name a manifest gives itself never counts, so no package can pose as another. Code reached by a relative import belongs to the package that imports it, even under an edge.json of its own, and a package two importers share loads once for each, so each copy holds only what its own importer passes it. An entry without a scope, such as "net", lets the package import the module and reach nothing, and code outside any package holds nothing.

What a dependency asks for and passes on

A dependency writes in its own edge.json what its code needs under main, and what each package it imports needs under that import key. It passes on only what it holds, so its importer has to give it everything it lists. A generic client such as http asks for "net" alone, and the package that imports it passes the hosts.

{ "name": "analytics", "imports": { "http": "0.2.0" }, "permissions": { "main": ["time:monotonic"], "http": ["net:api.telemetry.com"] } }
json

edge add prints what each new package asks for. edge lock walks the whole tree, each package through its own edge.lock, and writes nothing until the root gives each package it imports everything that package lists, naming each one that misses something. A package that passes an import less than it asks fails the lock the same way. edge build, edge run and edge test hold the tree to the same check before anything compiles, so an edit after the last lock cannot slip past it.

error: edge.json does not grant what these packages ask for
  analytics 1.2.0   net:api.telemetry.com, time:monotonic
help: grant them under "permissions" in edge.json
text

No import may be named all, main or eval, since the package it brings in would answer to that holder, and the registry refuses to publish a package named any of them.

Where a grant is checked

Importing a system module the package holds no entry for fails at compile time.

'analytics' imports net, which edge.json does not grant it
text

Every call checks its scope again, since a url is only known once the program runs. A call outside the grant raises PermissionError, a subclass of OSError, in the coroutine that made it.

import net

try:
    net.request("GET", "https://evil.example/")
except PermissionError as e:
    print(e)  # 'main' has no net:evil.example, edge.json grants it net:api.example.com
python

A request or socket belongs to the package that opened it, so another package cannot read it. A malformed section stops the run before anything compiles, naming the holder and the entry.

time

CallNeedsReturns
now(), now('wall')time:wallNanoseconds since the Unix epoch
now('monotonic')time:monotonicNanoseconds from an arbitrary start, never going back
zone()time:zone[name, offset], the IANA zone and its offset from UTC in seconds

When no package holds a time scope the engine runs on its virtual clock. sleep and with_timeout pass at once and in order, and a call to the host takes no time, so what a program prints never depends on when it runs. Any time scope turns the wall clock on for the whole run.

net

CallReturns
request(method, url, headers, body)An id at once, the request goes out in the background
response(id)[status, headers] once the head arrived
read(id)The next chunk of the body, or the next message of a socket, None once it ended
connect(url)A socket id once the WebSocket is open
send(id, data)Sends bytes or a str on a socket
close(id)Aborts a request or closes a socket

headers is a list of [name, value] pairs or a dict, and body is bytes, a str or None. A failure to connect or read raises OSError from the call that meets it.

A url names its host right after the scheme, with no user before it and no backslash, space or control character anywhere, so the check and the request always read the same host. Anything else raises ValueError, while an @ later in the path stays, as in https://edgepython.com/@dylan. A non-ASCII host raises too, since only its punycode form reads the same everywhere, and so does a host ending in a number unless it is written a.b.c.d, since a browser reads 0x7f.1 or 2130706433 as an IPv4.

The path is resolved before the check and sent as resolved, so both hosts read exactly one path. A . or .. segment is applied, however it is spelled, so /v2/%2e%2e/admin is /admin and a path prefix cannot be climbed out of, and text outside the unreserved set crosses as its escaped UTF-8 bytes, so /Español is sent as /Espa%C3%B1ol. A fragment is dropped, since it never leaves the host. No host follows a redirect. A 301, 302, 303, 307 or 308 raises OSError, and the new address goes through its own request and its own check. The headers fetch keeps for the host, such as Host, Cookie, Origin or any Sec- or Proxy- one, raise ValueError. In a browser the request is a fetch from the Web Worker, so CORS applies on top of the grant. The CLI runs the same calls in SpiderMonkey, with its own HTTP and WebSocket clients underneath, so a program answers the same with and without --web.

import net

r = net.request("GET", "https://api.example.com/items", [["accept", "application/json"]], None)
status, headers = net.response(r)
body = b""
chunk = net.read(r)
while chunk is not None:
    body += chunk
    chunk = net.read(r)
python

secret

CallNeedsReturns
read(name)secret:<name>The value the host keeps under that name, as a str

A name the package does not hold raises PermissionError, and one it holds but the host keeps no value for raises OSError. Nothing lists the names a host keeps, so a program reads only the values it was granted and asks for by name.

The CLI reads EDGE_SECRET_<name> from its environment at the moment of the call, so a program never reaches a variable it was not granted, whatever else the environment holds. With --web the CLI hands the page only the values a secret entry names. A page that embeds the engine passes them to createWorker as secrets.

import secret

token = secret.read("GITHUB_TOKEN")
python
EDGE_SECRET_GITHUB_TOKEN=ghp_example edge run main.py
bash

fs

CallNeedsReturns
read(path)fs: a folder holding pathThe text of that file, read as UTF-8, as a str
list(dir='.')fs: a folder holding dirEvery file under dir, as paths from the root edge.json, in order

A path is written from the root edge.json, such as shop/app/product.rb. One with .., a leading / or a name starting with a dot raises PermissionError, like one outside every folder the package holds. A file that is missing, is not UTF-8 or is over 10 MB raises OSError, and so does a list of more than 10,000 files. Nothing writes.

The CLI reads the disk, or the bundle a run came in, and never follows a link, even one pointing inside the project. A page reads the files beside its program through the same reader its imports use, and lists them from edge.files, the list edge build --web writes and edge serve and --web answer, since http cannot list a folder. A page reads only the paths that list names, so both hosts read the same files. A page that embeds the engine serves edge.files beside its edge.json, a JSON list of every path fs may read.

{ "permissions": { "main": ["fs:./shop"] } }
json
import fs

for path in fs.list("shop"):
    print(path)

code = fs.read("shop/app/models/product.rb")
python

batch

Every system module also takes batch(calls), a list of [name, *args] lists answered in one crossing, the results in the same order. A call that waits holds the batch until every one settles, the first failure raises in the coroutine that made the batch, and each call still checks its own scope.

import net
import time

now, zone = time.batch([["now"], ["zone"]])
ids = net.batch([["request", "GET", url] for url in ["https://api.example.com/a", "https://api.example.com/b"]])
heads = net.batch([["response", i] for i in ids])
python

Each crossing between the program and the host pays to decode the arguments and encode the answer, and a batch pays it once for all of its calls.

From a plugin

A .wasm plugin reaches the same calls through the Sys op of the ABI, with the grants of the package it sits in. A call that waits parks the program, and the plugin finishes its work in __edge_resume once the call settles, so the program sees what the plugin makes of the answer.

Embedding

A page that runs a program with createWorker passes what its root edge.json would grant beside the imports, or points baseUrl at the directory whose edge.json says it. The values secret reads go in secrets, and each is read only under a name the permissions grant.

const worker = await createWorker({
  baseUrl: new URL("./app/", location.href).href,
  imports: { calc: "./calc.py" },
  permissions: { main: ["net:api.example.com", "secret:API_KEY"] },
  secrets: { API_KEY: key },
});
js

Every worker lives in a sandboxed frame with an origin of its own, so a program holds none of the page’s cookies or storage and cannot reach its DOM, even one the page did not write. The page hands the frame the engine and reads the program’s files for it, and the browser lets the frame connect only to the origins its imports name and the hosts its permissions grant, so a request the grants would miss still never leaves.

Trace

A worker created with trace: true reports every system call of a run through onTrace, timed in milliseconds from the start of the run, which the first event gives as milliseconds since the epoch. An event names the package, the call, what it reached and how it ended, which for net is the method, host and path, for secret the name and for time the clock. It never carries a body, a header or a query, and shows a secret by its first two characters, none under eight. A print and a wait on the wall clock arrive in the same stream.

const worker = await createWorker({ trace: true, permissions: { main: ["net:api.example.com"] } });
worker.onTrace((event) => console.log(event));
// { kind: "call", at: 2.4, ms: 181, pkg: "main", call: "net.response", scope: "", outcome: "ok", id: 1, status: 200 }
js

A batch reports as one call. The calls inside it are not listed one by one.

See also

  • Modules, edge.json and where each package’s manifest sits.
  • Limits and errors, the exception tree PermissionError joins.