ADR-060: Skills that run code — never on our server, always in a sandbox
- Status: Accepted 2026-11-08 (plan track HO, wave 3: HO8), after Opus's security review.
- Owner: OpenVibe.Actor (the registry and the
skill.runstep) and OpenVibe Link (the runner). - Contracts:
actor.skill@2,actor.skill-listing@2;skill.runjoins the HO1 approval kinds. - Supersedes: ADR-058 decision 2's "no code" for
runs: "sandbox".actor.skill@1stays valid and is still what a prompt-only skill publishes.
Evidence
- The Hermes/OpenClaw study (
plan-state-2026-10-22/hermes-openclaw-2026-11-07.md) found the "magic moment" is an agent that learned something and used it later: a skill. Both projects quickly want that skill to *do* the thing, not only describe it, and both show the danger: OpenClaw's ClawHub needed a scan and a trust envelope because a skill that steals credentials is what people fear. - OpenVibe already has the trust spine: HO1 gives every consequential act a kind and an approval, HO2 made a skill a document whose
requiresgrants nothing, and Run already executes a registered artifact as a job on a Link with pinned input bytes and per-job network egress. - What OpenVibe did not have is a way to run a person's (or an agent's) code without putting it on our servers or trusting the person's laptop. A Link that declares the skill runner, and Run's
functionclass, is that place.
Decision
- The rule. A skill's code never runs on an OpenVibe server, never on the person's machine outside a sandbox, and never sees a secret. It runs as a Run job of class
functionon a Link that declared the skill runner, with the network denied unless the skill declared otherwise and the person approved it, and with the exact bytes the registry scanned. - The contracts.
actor.skill@2adds:
runs:prompt(exactly @1) orsandbox;tools(1 to 5, only whenruns: "sandbox"):name(^[a-z][a-z0-9_]{0,31}$), adescriptionthe agent reads before it calls (≤ 200),language: "javascript"(the only one in v1),entrynaming one of the skill's resources,argsas a JSON Schema subset (an object whose properties are string, number, integer, boolean or an array of those, withenum,maxLengthandpatternat most 200 characters; no$ref, no nested object),net(denyby default,openvibe-only,public) andlimits(wall_ms≤ 30000, default 10000;mem_bytes≤ 268435456, default 134217728);- code as an ordinary text resource (
.js,text/javascript, ≤ 64 KiB each), so it is in the same integrity hash as the instructions and resources; there are no binaries, no install recipes and no dependencies; requires.kindsgainsskill.runwhenrunsissandbox, and a prompt skill may not declare it.actor.skill-listing@2carries, per code resource,{ name, media_id, sha256 }, so the bytes that run are the bytes that were scanned, and its scan findings may name thetoolthey are about.actor.skill@1andactor.skill-listing@1are unchanged.
- The Link runner (
[email protected], a declared function like[email protected]):
- off unless the owner turns it on (
worker.functions,openvibe-link skills on); - takes
{ tool, entry, input }and the code as jobinputs(Media objects pinned by sha256; a mismatch ends the job before anything runs); - runs
node --permission --allow-fs-read=<the job's input dir>with no child process, no worker, no addons and--disallow-code-generation-from-strings, inside the function sandbox Link already has (its own root, limits, no credential, network pernet); - the input arrives on stdin as JSON; the tool writes one JSON value (≤ 64 KiB; more fails the job
limit); stderr is kept for the person (≤ 16 KiB).
- The Actor registry and the run.
- Publishing a sandbox skill stores each code resource in Media (Actor's tenant, private) and puts its id and sha256 on the listing. The scan reads the code and never runs it; it fails a skill that reads
process.env, spawns, loadsvm/worker_threads/child_process/fswrites, useseval/Function, reaches the network when the tool saysdenyor names non-OpenVibe hosts underopenvibe-only, carries long base64/hex, a minified one-liner over 2 KB or anything that decodes to code; it flags any network use, timers over 5 s and big loops over input. - Running a tool is a step of the new kind
skill.run(label: "running a skill's code in a sandbox"), target<slug>@<version> <tool>plus· internetwhennetispublic. "Always allow" covers exactly that skill version's tool, so a new version asks again. - Actor submits the Run job as the person (
X-OV-Subject+X-OV-Project): classfunction, artifact[email protected], the pinned inputs,netas declared (never wider), limits as declared (never above the caps),ttl_ms120000. Placement is the person's own Link machines that run the skill runner first, then OpenVibe's pool when one offers it; never Actor's server. No node means the step says so and offers "Run it on my machine" (Link setup), never a fallback that runs elsewhere. - Billing is Run's per-second metering on the person's Balance, inside HO7's per-agent allowance when the agent acts alone. The result is data: the JSON goes back to the agent as an untrusted tool result, never as instructions; the step records the job id, wall time and exit.
- The person sees it on the skill's card and install page: "Runs code in a sandbox on your machine or OpenVibe's", the tools with their
netin plain words ("no internet", "OpenVibe sites only", "the internet"), and the scan's findings per tool. Aflaggedcode skill installs only after reading them; afailednever.
Alternatives
- Run the code on Actor's server. Rejected: Actor holds every person's agent, token and registry record; one skill's mistake would be a registry-wide incident, and the design rule forbids it outright.
- A container on the host that publishes the skill. Rejected: the host is someone's machine, and a container boundary we do not control is not a sandbox promise we can make to every site. Link already has the sandbox, the limits, the egress policy and the placement; running there needs no second runtime.
- Python and npm in v1. Deferred: both widen the runtime surface (packages, install recipes, dependency resolution) before the scan and the sandbox have a release behind them. Each needs its own review, as does persistent state, a tool calling another tool, files written back to the person, and GPU.
Migration and rollback
Both new contracts are additive majors; nothing parses actor.skill@2 until Actor's registry and Link's runner ship, and @1 keeps working for every existing prompt skill. The only data a sandbox listing adds is the Media objects that hold the code; they are private to Actor's tenant and can be deleted with the listing. Rollback is retiring the @2 listing and turning the runner off: no prompt skill is affected, no job can be submitted without the skill.run kind, and nothing ever ran on our servers, so there is no server-side state to unwind.
Tests
test/run.js:
- every valid fixture carries the hash of its own canonical content, including code resources and tools, and an edited instruction, code or
netno longer matches it; runsispromptorsandbox;toolshas 1 to 5 entries and only a sandbox skill carries them;- a sandbox skill declares
skill.runinrequires.kinds; a prompt skill does not; languageisjavascript, the toolnameshape is the one above, andentrynames a code resource (the cross-field rule a JSON Schema cannot state: the fixtures and the registry check it);netisdeny,openvibe-onlyorpublicwithdenythe default;limitsstay at or under the caps and default to 10000 ms and 134217728 bytes;- a resource is text (no binary), code is
.jswithtext/javascriptand at most 64 KiB, and no field is namedsecret,token,envorkey; - a @2 listing pins each code resource's Media id and sha256, the pinned sha256 is the document's own bytes, and a sandbox listing without code is invalid.
Link's own tests (in OpenVibe Link) prove the runner: a tool cannot read env, spawn, write a file, open a socket under deny, or exceed its output.