Guides / Writing a kit
Writing a kit
A Sandbox Kit is a short file that describes the environment an AI agent runs in. It names the image, what gets installed, the one folder the agent can see, the hosts it can reach and the credentials it can use. kosa8 turns each line into something the sandbox enforces, so the limits hold whatever the agent decides to do.
What a kit decides
An agent told "only touch this project" can ignore the instruction. An agent whose virtual machine was only given that project's folder cannot. A kit is the list of things the machine is given.
node:22-alpine.packages are pinned to an exact version and a digest; setup is free-form
shell for whatever a package list cannot express.credentials stay on your computer and the sandbox holds a placeholder;
secrets are copied in as they are.What kosa8 agent run does
- The kit is loaded: a built-in, a file in
~/.kosa8/kits, or a path. A file is checked against its signature. - The run stops here if the kit is marked
unavailable, or ifsetupinstalls or downloads something that is not pinned. - kosa8 creates a fresh sandbox with the workspace mounted and, when the kit has
credentials, an egress broker holding the real keys. - The image is pulled into that sandbox. The service fetches it on your side, so the sandbox needs no network for this.
- Each pinned package is downloaded, its digest is compared with the one in the kit, and only then is it installed.
- The kit's
commandruns in the workspace folder, and its output is streamed back to your terminal. - When the command exits, fails, or you press Ctrl-C, the sandbox is removed, and its broker and credentials go with it.
This is what it prints, on the error stream, for the built-in claude kit:
$ kosa8 agent run -w ~/code/api claude kosa8: kit claude (node:22-alpine) kosa8: workspace /Users/you/code/api -> /work (nothing else on the host is visible) kosa8: network closed except through the egress broker: registry.npmjs.org, statsig.anthropic.com kosa8: brokering ANTHROPIC_API_KEY -> api.anthropic.com (the sandbox holds a placeholder, never the key) kosa8: sandbox claude-44059 ready in 653ms kosa8: pulling node:22-alpine kosa8: running kit setup, then: claude ... kosa8: sandbox claude-44059 removed
Start from a built-in
kosa8 ships kits for common agents. List them:
$ kosa8 agent kits
KIT IMAGE NETWORK KEYS DESCRIPTION
azmx node:22-alpine api.azmx.ai,registry.npmjs.org brokered + passed in UNAVAILABLE — AZMX AI agent CLI in an isolated sandbox
claude node:22-alpine registry.npmjs.org,statsig.anthr.. brokered Claude Code in an isolated sandbox
codex node:22-alpine registry.npmjs.org brokered OpenAI Codex CLI in an isolated sandbox
copilot node:22-alpine api.githubcopilot.com,api.github.. passed in GitHub Copilot CLI in an isolated sandbox
cursor node:22-alpine api2.cursor.sh,api.cursor.sh passed in UNAVAILABLE — Cursor CLI (cursor-agent) in an isolated sandbox
gemini node:22-alpine registry.npmjs.org brokered Gemini CLI in an isolated sandbox
opencode node:22-alpine registry.npmjs.org brokered OpenCode in an isolated sandbox
shell alpine:latest none none A plain shell with the workspace mounted and no network
brokered: the key stays on this machine and the sandbox holds a placeholder
passed in: the real key is in the sandbox's environment
kits in ~/.kosa8/kits override built-ins of the same name
Copy a built-in to a file
$ kosa8 agent init claude
wrote /Users/you/.kosa8/kits/claude.yaml
The file is YAML, readable only by you, and starts with a comment reminding you that everything in it is enforced by the sandbox.
Edit it
Open ~/.kosa8/kits/claude.yaml in any editor. Give it a different
name and save it under that name if you want to keep the built-in as well. The
fields are listed below.
See what it would do
$ kosa8 agent run --dry-run -w ~/code/api ./team-claude.yaml
kosa8: warning: kit file ./team-claude.yaml is unsigned: nothing vouches for its contents (sign it with `kosa8 agent sign`, and pin the key with `kosa8 policy trust`)
kosa8: kit team-claude (node:22-alpine)
kosa8: workspace /Users/you/code/api -> /work (nothing else on the host is visible)
kosa8: network closed except through the egress broker: registry.npmjs.org, statsig.anthropic.com
kosa8: brokering ANTHROPIC_API_KEY -> api.anthropic.com (the sandbox holds a placeholder, never the key)
kosa8: passing CI into the sandbox as plain environment variables — the agent can read them
kosa8: --dry-run, stopping before creating the sandbox
--dry-run prints the plan and creates nothing. Read it before the first real
run of any kit you did not write.
Run it, by name or by path
$ kosa8 agent run team-claude # ~/.kosa8/kits/team-claude.yaml $ kosa8 agent run ./kits/team-claude.yaml # any path
An argument that ends in .yaml or .yml, or contains a
/, is read as a path. Anything else is a name: first a file in
~/.kosa8/kits, then a built-in.
The kit file, field by field
Only image is required. A kit file that leaves out name takes the file's
name without its extension.
| Field | Type | Default | What it does |
|---|---|---|---|
name | string | the file name | What the kit is called. The sandbox is named NAME- and a number unless you pass --name. |
image | string | required | The image the agent runs in. A kit with no image does not load: a kit must specify an image. |
setup | list of strings | none | Shell lines run once, in order, after the pinned packages and before the command. A line that installs or downloads is refused unless you pass --allow-unpinned. |
command | list of strings | none | What the kit exists to run. Extra arguments after the kit name on the command line are added to the end. |
workspace | string | current directory | The folder on your computer the agent sees. -w overrides it. It must be an existing directory. |
mount_at | string | /work | Where the workspace appears inside the sandbox. The command starts there. |
egress | string | empty | deny for no network at all. Left empty, egress_allow decides. |
egress_allow | list of strings | none | The only hosts the sandbox may reach. With neither this nor egress: deny the network is open, and every run says so: network UNRESTRICTED — this kit sets no egress_allow. |
secrets | list of strings | none | Names of environment variables on your computer whose real values are copied into the sandbox. The agent can read them. Prefer credentials. |
env | map of strings | none | Fixed environment variables for the sandbox. Not for secrets: the kit file is not one. |
cpus | whole number | 2 | Virtual CPUs for the sandbox. |
mem_mb | whole number | 1024 | Memory for the sandbox, in MiB. |
description | string | none | One line saying what the kit is for. kosa8 agent kits shows it for built-ins. |
packages | list of packages | none | npm packages installed globally, each pinned to one version and the digest of that version's tarball. |
credentials | list of credentials | none | API keys the agent uses without holding. Listing any puts the sandbox behind an egress broker. |
unavailable | string | empty | When set, the reason this kit cannot be run. kosa8 agent run refuses with it. kosa8 agent init leaves it out of the copy it writes. |
Setup and the command run as one shell line:
sh -lc "…setup… && cd /work && COMMAND ARGS". The words of
command are the kit author's shell text and are joined with spaces as written, so a
word containing spaces does not stay one word; keep command to a program and simple
flags, and put anything longer in a script in the workspace. Arguments you type after the kit
name are different: each is quoted, so one with a space or a semicolon arrives as that one
argument and never as more shell.
A package
| Field | Type | What it must be |
|---|---|---|
name | string | An npm package name, scoped or not: lower-case letters, digits, ., _ and -. |
version | string | One exact version, such as 2.1.296 or 1.0.0-beta.2. A range or a tag is refused. |
integrity | string | sha512- followed by the base64 digest of the tarball, exactly as the registry publishes it. |
A credential
| Field | Type | Default | What it does |
|---|---|---|---|
name | string | required | The route's name and the first part of the address the sandbox calls. 1–32 characters of a-z, 0-9 and -; unique within the kit. |
upstream | string | required | The only place the key is ever sent: https://host, with no path. |
secret | string | required | The environment variable on your computer that holds the real key. Read by kosa8 agent run. |
header | string | required | The header that carries the key to the upstream, for example x-api-key or Authorization. |
scheme | string | empty | Text put before the key in the header, for example "Bearer " with its trailing space. |
methods | list of strings | any | The HTTP methods the key may be used with. |
paths | list of strings | any | Path prefixes on the upstream the key may be used for. Each must start with /. |
env | map of strings | required | What the sandbox is given instead. {{url}} becomes the route's address at the broker and {{placeholder}} the stand-in for the key. Some variable must contain {{placeholder}}. |
A kit with credentials reaches every host through the broker, by name. Its
egress_allow may therefore list host names or single addresses, but not a network such
as 10.0.0.0/8; that is refused when the kit is loaded. How the broker uses these
fields, and what a refused request looks like, is in
Keep API keys out of the sandbox.
Two complete kits
A shell tool with no network
name: lint description: A shell with the project mounted and no network image: alpine:latest command: [sh] egress: deny cpus: 2 mem_mb: 1024
$ kosa8 agent run --dry-run -w ~/code/api ./lint.yaml kosa8: warning: kit file ./lint.yaml is unsigned: nothing vouches for its contents (sign it with `kosa8 agent sign`, and pin the key with `kosa8 policy trust`) kosa8: kit lint (alpine:latest) kosa8: workspace /Users/you/code/api -> /work (nothing else on the host is visible) kosa8: network denied kosa8: --dry-run, stopping before creating the sandbox
An agent with a pinned package, a brokered key and an allow-list
name: team-claude
description: Claude Code for the API team
image: node:22-alpine
packages:
- name: "@anthropic-ai/claude-code"
version: 2.1.296
integrity: sha512-OX/k/rpcthMqnFKOWcpNcbiLsMKtAjLOpQNJHlYiDCFUznWCIwdRTyl/p9SdHVrsrzZuPMCNqZjnpakIni6upw==
command: [claude]
mount_at: /work
egress_allow:
- registry.npmjs.org
- statsig.anthropic.com
credentials:
- name: anthropic
upstream: https://api.anthropic.com
secret: ANTHROPIC_API_KEY # read on your computer; never enters the sandbox
header: x-api-key
methods: [POST] # optional
paths: [/v1/messages] # optional
env: # what the sandbox gets instead
ANTHROPIC_BASE_URL: "{{url}}"
ANTHROPIC_API_KEY: "{{placeholder}}"
env:
CI: "1"
cpus: 4
mem_mb: 4096
The package, version and digest here are the ones the built-in claude kit pins. If
ANTHROPIC_API_KEY is not set where you run the command, kosa8 warns and leaves that
route out, so the agent fails to sign in rather than reaching the upstream with nothing:
kosa8: warning: ANTHROPIC_API_KEY is not set on the host; the agent may fail to authenticate
Flags for kosa8 agent run
| Flag | What it does |
|---|---|
-w, --workspace DIR | The folder the agent may see. Default: the kit's workspace, then the current directory. |
--name NAME | Name the sandbox yourself. |
--keep | Keep the sandbox after the agent exits. |
--no-network | Deny all network access, overriding the kit. No broker is started, so brokered keys are unavailable too. |
--dry-run | Show what would run without creating the sandbox. |
--allow-unpinned | Run a kit whose setup installs software that is not pinned to a version and digest. |
Everything after the kit name is handed to the agent's command, flags included.
kosa8 agent run -w ~/code/api claude sets the workspace.
kosa8 agent run claude -w ~/code/api runs claude -w ~/code/api in the
current directory.
The full flag list is on the agent run reference page.
Pinning what a kit installs
npm install -g some-cli installs whatever the registry serves at that minute, next
to your API key. A pinned package names one version and the SHA-512 digest of that version's
tarball, so every run installs the same bytes or stops.
Ask the registry for the digest of the version you have reviewed, and copy both into the kit:
$ npm view @anthropic-ai/[email protected] dist.integrity sha512-OX/k/rpcthMqnFKOWcpNcbiLsMKtAjLOpQNJHlYiDCFUznWCIwdRTyl/p9SdHVrsrzZuPMCNqZjnpakIni6upw==
For each package kosa8 generates one install step. This is it, with the name, version and digest
filled in from the kit (the digest is converted to the hexadecimal form sha512sum
prints):
t=$(cd /tmp && npm pack --silent NAME@VERSION) \ && echo "SHA512_HEX /tmp/$t" | sha512sum -c - >/dev/null \ && npm install -g --silent "/tmp/$t" \ && rm -f "/tmp/$t"
npm packdownloads the exact tarball to/tmp.sha512sum -ccompares it with the digest in the kit.npm install -ginstalls that file, never the name, which npm would look up again.
The steps are joined with &&, together with setup and the
command, so one failing stops everything after it.
- The kit carries the digest of the tarball you reviewed.
- At run time the tarball is downloaded again, from the registry, inside the sandbox.
- Before anything is installed, the download is hashed and compared with the kit.
- The same bytes: that file is installed and the run goes on.
- Different bytes, because the version was replaced or the registry was tampered with: the step fails and nothing after it runs.
A failed check ends the run before the agent starts, with this from sha512sum:
sha512sum: WARNING: 1 of 1 computed checksums did NOT match
A package that is not written exactly is refused when the kit is loaded:
$ kosa8 agent run --dry-run ./bad.yaml kosa8: ./bad.yaml: package cowsay: version "latest" is not exact (a range or a tag such as "latest" is whatever the registry says today)
What counts as unpinned
kosa8 reads each setup line and refuses the kit if any of them fetches code to run.
The check is on the words of the line, in upper or lower case:
| Tool | Refused when the line contains |
|---|---|
| JavaScript | npm, pnpm, yarn or bun followed by install, i, add, exec or dlx; or npx |
| Python | pip, pip3, pipx or uv followed by install, tool or run |
| System packages | apk, apt, apt-get, dnf or yum followed by add or install |
| Other languages | go install, cargo install, gem install |
| Downloads | curl or wget, anywhere in the line |
$ kosa8 agent run --dry-run ./unpinned.yaml kosa8: kit quick installs software that is not pinned to a version and digest: npm install -g cowsay list it under `packages:` with a version and integrity, or pass --allow-unpinned to run whatever the registry serves today
--allow-unpinned accepts those lines for one run. Use it while you are writing a kit,
not for one a team depends on.
Signing kits
A kit decides what an agent can reach, so whoever can write a kit file decides that too. Signing lets an organisation say which kit files are its own, and lets each computer refuse the rest. It uses the same key that signs company rules, so one key vouches for policy and kits alike.
Make a signing key, once
$ kosa8 policy keygen -o org.key
public: kQ+JPoaWWs9JC1Nic/sRli1nJOAgQFAmmm+xtD0oEeo=
private key written to org.key (keep it secret)
The public half is also written to org.key.pub. Skip this step if your
organisation already has a policy key.
Sign the kit
$ kosa8 agent sign org.key team-claude.yaml
signed team-claude.yaml -> team-claude.yaml.sig
The signature is a small file beside the kit. It covers the kit's bytes exactly, so any
edit, even to a comment, needs a new signature. A kit that would not load is not signed:
the command stops with refusing to sign: and the reason. Distribute the two files
together.
Pin the key on each computer
$ kosa8 policy trust org.key.pub
trusted policy signer set
From now on this computer runs only kit files signed by that key. Built-in kits are part of kosa8 itself and are not affected.
Making a key, signing and pinning need the Governance subscription or a trial. Checking a signature never does.
What happens to a kit file
| On this computer | The kit file is | Result |
|---|---|---|
| No key pinned | unsigned | loads, with a warning kit file PATH is unsigned: nothing vouches for its contents (sign it with `kosa8 agent sign`, and pin the key with `kosa8 policy trust`) |
| signed, signature matches | loads with no warning, whoever signed it. | |
| signed, signature does not match | refused kit PATH does not match its signature: it was changed after it was signed, or signed with a different key | |
| A key is pinned | unsigned | refused kit PATH is not signed, and this machine only runs kits signed by its org's key; have it signed with `kosa8 agent sign` |
| signed by the pinned key | loads | |
| signed by another key | refused kit PATH is signed by a key this machine does not trust | |
| changed after signing | refused kit PATH does not match its signature: it was changed after it was signed, or signed with a different key |
A bad signature is refused even where no key is pinned: somebody signed that kit, and these are not the bytes they signed.
A kit file with a built-in's name
A file in ~/.kosa8/kits takes the place of the built-in with the same name. That is
how kosa8 agent init claude lets you customise claude, and it is also how
something else writing to that folder could swap the kit you think you are running. So every run
says when it has happened:
kosa8: warning: kit file /Users/you/.kosa8/kits/claude.yaml replaces the built-in kit of the same name
Where a key is pinned the replacement must also be signed by it, or it does not load at all.
The two built-ins that do not run
cursor and azmx are listed as UNAVAILABLE. Each used to
install an npm package by name, and neither name can be vouched for. kosa8 refuses, with the reason,
instead of installing something nobody checked and handing it an API key.
| Kit | Why | What you see |
|---|---|---|
cursor | The npm package cursor-agent is published by an account that is not Cursor. Cursor distributes its CLI through an install script, which cannot be pinned to a digest. | kosa8: kit cursor cannot be run as shipped: the npm package "cursor-agent" is not published by Cursor, so this kit no longer installs it; write a kit that installs Cursor's CLI from a source you have verified (`kosa8 agent init cursor`) |
azmx | The npm name @azmx/cli is not registered, so anyone could publish under it and be installed here. | kosa8: kit azmx cannot be run as shipped: the npm package "@azmx/cli" is not published, so there is nothing verified to install; pin the package and its integrity in this kit once it is released |
kosa8 agent init cursor writes a copy without the unavailable line. That
copy is yours to make runnable: add an install step from a source you have checked yourself.
What this does not do
- Dependencies of a pinned package are not pinned. The digest covers that package's own
tarball. npm resolves its dependencies at install time, and those can change. If you need them
fixed, build an image that already contains the tool and use that as the kit's
image. - Images are named by tag.
node:22-alpineis whatever the registry serves under that tag today. kosa8 does not pin or verify the image for you. - A signature says the organisation approved these bytes, not that they are safe. A signed kit with an open network and a passed-in key is still an open network and a passed-in key.
- The unpinned check reads words, not meaning. It catches the usual installers and
downloaders in
setup. It does not understand a script already in the image or the workspace that downloads something itself. secretsare not protected. The real value is in the sandbox's environment, where the agent and everything it installs can read it and send it to any host the kit allows.- Pinning the key is local. The pinned key is a file in
~/.kosa8. Someone who can delete it on their own computer can run unsigned kits there; protect it the way Policy reference describes.