# kosa8 docs, in full > kosa8 makes sealed sandboxes for AI agents (coding agents, data analysis, evals and RL rollouts, agent frameworks, MCP tools): each agent runs code unattended in a microVM of its own. On your Mac, Linux or Windows machine you can checkpoint, rewind any mistake and fork; kosa8 Cloud runs sandboxes on kosa8's hosts, billed per second; and it also runs on your own servers or fully air-gapped. These docs cover every guide and every command, generated from the binary. # kosa8 for AI agents You are an AI agent, and the person you work for wants you to use kosa8: sealed sandboxes for AI coding agents. Code you run goes into a microVM of its own, not onto their machine. On their own Mac (or Linux or Windows machine) any mistake can be rewound to a checkpoint; kosa8 Cloud runs sandboxes on kosa8's hosts, billed per second. This page is written for you. Follow it in order. Every step is a command you can run, and the one step that needs the person says so. kosa8 is commercial software with a free tier: after signing up, sandboxes, save and undo, and the dashboard work with no plan. Forks, local models, Explore, the assistant and governance need a plan, from $29 a month, $30 off the first payment (the checkout takes a card and charges at once): https://kosa8.com/pricing/ . kosa8 Cloud gives $30 of credit once a card is on file. There is no time-limited trial. ## 1. Is kosa8 installed? ``` kosa8 version ``` If that works, go to step 3. If the command is not found, install it. ## 2. Install macOS and Linux: ``` curl -fsSL https://kosa8.com/install | sh ``` Windows (PowerShell): ``` irm https://kosa8.com/install.ps1 | iex ``` Tell the person what you are installing before you run it, the same as any installer. Then run `kosa8 doctor`. It checks the machine and says what to fix, in plain words. Windows sandboxes need Docker with gVisor, and `kosa8 doctor` says if they are missing. ## 3. Sign up (this step needs the person) A new install needs the person to sign in (or sign up) before it runs. Commands like `kosa8 prompt` will refuse until they have. Check first: ``` kosa8 signup --json ``` If it says `"signed_up": true`, go to step 4. If it says `"signed_up": false`, the easiest way is the browser sign-in: ``` kosa8 login --no-browser ``` It prints a short code and a link (to stderr), then waits up to ten minutes. **Give the person both** and ask them to open the link, sign in or create their account, and confirm the code. The command finishes by itself once they have, and the machine is signed up with their account's email. Run it with a long timeout or in the background. Never sign in on their behalf. If they would rather not use a browser, use the emailed code: 1. **Ask the person which email address to use.** Never make one up, and never use an address you found somewhere else. The account belongs to them. 2. Send the code: ``` kosa8 signup THEIR_EMAIL ``` 3. **Ask the person for the six-digit code** that just arrived in their inbox. It works for 30 minutes. Never try to read their email to get it yourself. 4. Finish: ``` kosa8 signup THEIR_EMAIL --code 123456 ``` Signing up puts the machine on the free tier (no trial, no card) and subscribes the address to the weekly kosa8 newsletter, which has a one-click unsubscribe. Tell the person that when you ask for the address. If the person is at this machine with a browser, `kosa8 login` is the other way: it opens their browser to sign up or sign in, to the one kosa8 account used by the CLI, the desktop app and app.kosa8.com. If they already have a licence key: `kosa8 license activate KEY`. ## 4. Start the daemon ``` kosa8 daemon start ``` It runs in the background and keeps running. `kosa8 doctor` confirms it is up. ## 5. Give a task to an agent in a sandbox The shortest path is one command: ``` kosa8 prompt "add a test for parseConfig and make it pass" ``` That makes a fresh sandbox, shares the current directory into it at `/work`, runs Claude Code headless on the task, prints the agent's output, and removes the sandbox. The exit status is the agent's. The agent's network is limited to the hosts its kit allows. Its API key stays on the host, in kosa8's broker, and the sandbox only ever holds a placeholder. Every tool call is checked before it runs, and everything is in the audit log (`kosa8 audit ls`). - Another agent: `-a codex`, `-a gemini`, `-a opencode`, `-a copilot`, or `-a shell` for a plain shell command. - Another folder: `-w ~/src/api`. - No network at all: `--no-network`. - Long input: pipe it in, `git diff | kosa8 prompt -`. - See the plan without running: `--dry-run`. - Keep the sandbox afterwards: `--keep`. `kosa8 sandbox ls` shows it, and `kosa8 sandbox rm NAME` removes it. The agent needs its provider's key on the host: for claude, either `CLAUDE_CODE_OAUTH_TOKEN` (the person's Claude subscription; they make it with `claude setup-token` in their own terminal) or `ANTHROPIC_API_KEY`; `OPENAI_API_KEY` for codex; `GEMINI_API_KEY` for gemini. If one is missing, `kosa8 prompt` warns. Ask the person to set it. Don't go looking for it in their files. ## 6. Or work in sandboxes yourself, with MCP tools To run code yourself in a sandbox, instead of handing a task to another agent: ``` kosa8 mcp install # adds kosa8's tools to Claude Code, Claude Desktop, Cursor and Codex kosa8 skill install # teaches you when and how to use them ``` The person may need to restart their agent for the tools to appear. The tools are `sandbox_create`, `sandbox_shell`, `sandbox_code_run`, the `sandbox_file_*` tools, `sandbox_git`, `sandbox_checkpoint`, `sandbox_rewind`, `sandbox_fork`, `sandbox_preview` and `sandbox_delete`. `kosa8 skill show` prints the guide. ## When kosa8 says it needs a plan On the free tier, sandboxes, save and undo, and the dashboard work; forks, local models, Explore, the assistant and governance need a plan. Every refusal at a plan limit (too many forks, a sandbox disk over the plan's size, a product the free tier or the plan does not carry) ends in one line naming the plan that unlocks it, its price, the $30 off the first payment, and the command. Run with `--json` and the refusal is also written to stdout as data: ``` { "error": "Solo allows 4 forks at once; ...", "upgrade": { "plan": "pro", "kind": "plan", "name": "Pro", "price_usd": 89, "price": "$89 a month", "unlocks": "16 forks at once", "url": "https://buy.polar.sh/...", "command": "kosa8 upgrade pro", "first_payment_discount_usd": 30 } } ``` `kind` is `plan`, `addon` (a capacity add-on; `plan` is its id) or `contract` (Enterprise: `price_usd` is 0 because it is quoted, and `url` is where to talk to a person). Per-person plans add `min_seats`. A plan offer carries `first_payment_discount_usd: 30`, the amount the checkout takes off the first payment; add-ons and contracts do not. The exit code is 1. - Relay `upgrade` to the person as it is: what it unlocks, the price, the $30 off the first payment, and the command or link. Don't round the price or pick a different plan. - Check where the machine stands: `kosa8 license status --json`. It carries `plan` (`unlicensed` is the free tier) and, with no plan, the same `upgrade` object for the plan that fits what this machine ran. A machine that started a trial before kosa8 stopped offering them keeps its remaining days (`plan: "trial"`, `trial_days_left`); no new trial starts. - Buying is the person's decision, and payment is theirs to make. Never run `kosa8 upgrade` without `--no-open` on their behalf: with `--no-open` it only prints the checkout link. The checkout takes their card and charges at once ($30 off the first payment); nothing is charged until they complete it. Plans are compared at https://kosa8.com/pricing/ - After they pay, Polar emails them a licence key. Ask them for it, then run `kosa8 license activate KEY`. - More forks than the plan allows: tell the person the refusal's options (remove a fork, a $49 Burst Pass for 24 hours, the plan in `upgrade`, or overage, which they turn on with `kosa8 license overage on`). Never turn overage on yourself; it bills them by the hour. ## No sandbox engine on this machine? kosa8 runs sandboxes on Apple-silicon Macs, x86-64 Linux and Windows 11 (through WSL2). On anything else, or in a CI job or hosted agent, use kosa8 Cloud: the person signs up at https://app.kosa8.com/, adds a payment method, and gives you an API key (`KOSA8_API_KEY`). Guide: https://docs.kosa8.com/guides/cloud.md ## Rules - If kosa8 refuses something for policy or approval reasons, tell the person what the refusal says. Don't look for a way around it. Approvals are for a human: `kosa8 mcp asks` lists any waiting. - Say which sandbox you used, so the person can look: `kosa8 sandbox ls`. - `--json` works on most commands and gives you output you can parse. ## More - Every guide and command, as markdown: https://docs.kosa8.com/llms.txt - The whole manual in one file: https://docs.kosa8.com/llms-full.txt - Any docs page as markdown: add `.md` to its address, for example https://docs.kosa8.com/reference/prompt.md # Install kosa8 Two ways in. Pick the Mac app if you want an icon to double-click; pick the one-line installer if you live in a terminal. They install the same thing. ## The Mac app Download `kosa8__macos_app.zip` (the newest release) from [the releases page](https://github.com/AzmxAI/kosa8-releases/releases/latest), unzip it, and drag **kosa8.app** to your Applications folder. The app carries the command-line tool and the background service inside it, so this is the only thing you need — there is nothing to install first. It is signed and notarized by Apple, so it opens without a warning. ## One line ``` $ curl -fsSL https://kosa8.com/install.sh | sh ``` The installer works out whether you have an Apple or Intel chip, checks the download against the published fingerprints before anything runs, and puts `kosa8` on your PATH. ### Homebrew ``` $ brew tap AzmxAI/kosa8 $ brew install kosa8 ``` ### Windows ``` PS> irm https://kosa8.com/install.ps1 | iex ``` ### Linux The same one-line installer works, and there are `.deb`, `.rpm` and `.apk` packages on the releases page. **Which machines run sandboxes** Sandboxes are real virtual machines, so they need a hypervisor kosa8 can drive. - **Apple Silicon Mac** — on Apple's Virtualization framework. - **x86-64 Linux** — on KVM with Cloud Hypervisor. The installer puts the daemon in place and says what else to install: `cloud-hypervisor`, `virtiofsd`, and access to `/dev/kvm`. `kosa8 doctor` checks all of it. - **Windows 11 on x86-64** (preview) — in WSL2. After installing, run `kosa8 wsl setup` once: it sets up the Linux engine in an Ubuntu distribution and connects the CLI to it. Everything else — an Intel Mac, Windows 10, Windows on ARM, JioOS on a JioBook — drives one of those. On that machine run `kosa8 daemon start --listen 127.0.0.1:7440` and `kosa8 worker pair --url https://…`; here, `kosa8 worker join` with the code it prints. Every command then works normally. ## Check it worked ### Sign in, or sign up A new install asks you to sign in once, before anything else runs. `kosa8 login` shows a short code and opens your browser, where you sign in or create your free account and confirm the code. The machine is then on the free tier: sandboxes, save and undo, and the dashboard. The same sign-in covers the desktop app and kosa8 Cloud. Forks, local models, Explore, the assistant and governance need a plan: `kosa8 upgrade` opens the checkout, which takes your card, $30 off your first payment. kosa8 Cloud also needs a card, and gives you $30 of credit to start. Have a licence key already? Run `kosa8 license activate ` instead. ``` $ kosa8 login ``` No browser on this machine? `kosa8 login --no-browser` prints the link to open on any device, or `kosa8 signup` emails you a six-digit code instead (that address also gets the weekly kosa8 newsletter, which has a one-click unsubscribe). Using kosa8 a lot on the free tier or Solo? About once a week at most, after a command, kosa8 prints one line naming what the next plan adds and the `kosa8 upgrade` command for it. It never appears with `--json`, in CI, or off a terminal; set `KOSA8_NO_UPGRADE_HINTS=1` to turn it off. ### Start the background service Nothing runs until the daemon is up. The Mac app starts it for you. ``` $ kosa8 daemon start ``` ### Run something The first run fetches the guest image for your release (a few hundred megabytes), checks it against the release's published checksum, and keeps it in `~/.kosa8/guest`. Later runs start straight away. With `KOSA8_OFFLINE=1` nothing is fetched: copy the guest image in from an air-gap bundle. ``` $ kosa8 run alpine echo hi hi ``` ### If anything looks wrong `kosa8 doctor` checks the parts that commonly go wrong — the daemon, the guest images, the virtualization entitlement — and tells you which one is missing. ``` $ kosa8 doctor ``` **Next** [Your first sandbox](https://docs.kosa8.com/guides/first-sandbox/) — make a sealed machine and run something inside it. **Using kosa8 from code?** For Node, install `@kosa8/sdk` (or `@kosa8/ai-sdk-sandbox` for the Vercel AI SDK) with the daemon running, and follow [Install and prerequisites](https://docs.kosa8.com/guides/js-install/) and the [Quickstart](https://docs.kosa8.com/guides/js-quickstart/). Docker clients such as dockerode and Testcontainers work by setting `DOCKER_HOST=unix://$HOME/.kosa8/run/kosa8.sock`. --- Source: https://docs.kosa8.com/guides/install/ # Your first sandbox A sandbox is a whole separate computer running on your machine — its own kernel, its own container engine, its own network. This walkthrough takes about five minutes. ### Make one ``` $ kosa8 sandbox create web --memory 2048 sandbox web ready in 640ms (32 GB disk) ``` Under a second for a machine with its own operating system. That speed is the reason throwing one away and making another is a reasonable thing to do. ### Put an image in it Each sandbox has its own private image store. Nothing is shared with your host, so an image pulled here cannot affect anything outside. ``` $ kosa8 sandbox pull web alpine:3.20 pulled docker.io/library/alpine:3.20 into web ``` ### Run something in it ``` $ kosa8 sandbox run web alpine:3.20 sh -c "hostname" kosa8-guest ``` A different computer name, because it really is a different computer. ### See what you have ``` $ kosa8 sandbox ls SANDBOX STATE CPUS MEMORY DISK PARENT LABEL web running 2 2048MiB 32GB - - ``` The **PARENT** column matters later: when you copy a machine, the copies remember where they came from. ## Decide what it can reach A sandbox with no limits is just a slower container. The point is deciding, up front, what it can see and where it can connect. ``` # only these two hosts, nothing else $ kosa8 sandbox create build --egress allow:registry.npmjs.org,github.com # no network at all $ kosa8 sandbox create offline --egress deny # show it one folder, read-only $ kosa8 sandbox create review --mount ~/code/api:/work:ro ``` **These are enforced, not requested** The limits are applied by the machine itself. Something inside that tries to reach a host you did not allow gets a failed connection — there is nothing for it to talk its way around. ## How much room it has ``` $ kosa8 sandbox create web --disk 500 sandbox web ready in 658ms (500 GB disk) ``` A sandbox's images, layers and files live on their own disk, separate from the guest root image — so a guest rebuild never touches them, and the size is declared rather than paid for. The image is sparse: a 1 TB sandbox occupies about 22 MB until something is written to it, and forking one stays as fast as forking a small one. The ceiling comes from your plan, and capacity above it is its own add-on — 500 GB at $100/mo, 1 TB at $200/mo. The disk is your own SSD; what the add-on covers is kosa8 snapshotting, forking and pushing a sandbox that size. `kosa8 license status` shows what yours allows. ## Stopping without losing it ``` $ kosa8 sandbox down web stopped web (start it again with `kosa8 sandbox up web`) $ kosa8 sandbox up web sandbox web ready in 594ms ``` A stopped sandbox keeps its own disk, so it comes back with everything in it — packages installed, images pulled, files written. The guest's filesystems are flushed before the machine goes away, which is what makes that safe rather than merely likely. The same is true of a daemon restart. A sandbox's VM cannot outlive `kosa8d`, so after `kosa8 daemon start` your sandboxes are listed as `stopped` and `sandbox up` boots them again. What does not come back is running processes: for that, take a [snapshot](https://docs.kosa8.com/guides/undo/) first — resuming mid-process is what a snapshot is for. ## Freezing it mid-step ``` $ kosa8 sandbox pause web paused web (resume it with `kosa8 sandbox resume web`) $ kosa8 sandbox pause --all paused web, claude-44059 $ kosa8 sandbox resume web resumed web ``` Pausing stops the sandbox's CPUs where they are. Nothing inside is told and nothing is torn down: memory, disks and connections stay exactly as they were, and nothing in it runs until you resume it. It is the button for an agent doing something it should not — freeze it, take a [snapshot](https://docs.kosa8.com/guides/undo/) to look at (a snapshot of a paused sandbox leaves it paused), then resume it or remove it. While it is paused, commands that need the guest are refused rather than left waiting, and stopping it skips the flush, since a frozen guest cannot write anything out. ## Clean up ``` $ kosa8 sandbox rm web removed web ``` If the sandbox had checkpoints, they go with it, and so do the snapshots behind them. Leaving them would mean a future sandbox that reused the name could rewind into a stranger's machine. **Next** [Save and undo](https://docs.kosa8.com/guides/undo/) — the part no other container tool does. --- Source: https://docs.kosa8.com/guides/first-sandbox/ # kosa8 Cloud The same sealed sandboxes, running on kosa8's hosts instead of your own machine. Nothing to install: sign in at [app.kosa8.com](https://app.kosa8.com/), make an API key, and create a sandbox from code in about a second. ### Create your account Open [app.kosa8.com](https://app.kosa8.com/). Under **New to kosa8 Cloud?** enter your email and a password (any address works, Gmail included; a team or company name is optional), and press **Create account**. From then on you sign in with that email and password; **Email me a link to set it** on the sign-in page resets a forgotten one. You also get the organization's first **admin API key** (it starts `k8c_live_`). It is shown once, so put it in your password manager before you continue. ### Add a payment method A new organization runs nothing until a payment method is on file. Go to **Usage** and press **Add payment method**; checkout is through Polar. Once it is added, a one-off **$30 signup credit** lands on the account and pays for your first usage. ### Make an API key for your code In **API keys**, create a key for each app or agent, so you can revoke one without the others. The SDKs read it from `KOSA8_API_KEY`: ``` $ export KOSA8_API_KEY=k8c_live_… ``` From the terminal, `kosa8 login` signs this machine in without copying a key: it shows a short code, you confirm it in your browser, and the key it makes is kept in `~/.kosa8/cloud.json` for the `kosa8 cloud` commands and the desktop app. `kosa8 whoami` shows which account and organization are signed in. ``` $ kosa8 login ``` ### Create a sandbox and run something Python (`pip install kosa8`): ``` from kosa8 import Kosa8Cloud cloud = Kosa8Cloud() # reads KOSA8_API_KEY box = cloud.sandboxes.create(template="python", sleep_after_s=600) r = box.exec(["python3", "-c", "print(6 * 7)"]) print(r.exit_code, r.stdout) # 0 42 box.sleep() # costs only snapshot storage while asleep box.exec("ls /workspace") # wakes it, files and all box.delete() ``` JavaScript and TypeScript (`npm install @kosa8/sdk`): ``` import { Kosa8Cloud } from '@kosa8/sdk'; const cloud = new Kosa8Cloud(); // reads KOSA8_API_KEY const box = await cloud.sandboxes.create({ template: 'python' }); const r = await box.exec(['python3', '-c', 'print(6 * 7)']); console.log(r.exitCode, r.stdout); // 0 "42\n" await box.delete(); ``` Go, Ruby and Java have the same `Kosa8Cloud` client; see each SDK's README under [For developers](https://docs.kosa8.com/guides/developers/). ## Plain HTTP The API is at `https://api.kosa8.com`. Send the key as a bearer token: ``` curl -s -X POST https://api.kosa8.com/v1/sandboxes \ -H "Authorization: Bearer $KOSA8_API_KEY" -H 'Content-Type: application/json' \ -d '{"template":"python"}' # {"sandbox":{"id":"sbx_…","state":"running","template":"python","region":"eu-hel1",…}} curl -s -X POST https://api.kosa8.com/v1/sandboxes/sbx_…/exec \ -H "Authorization: Bearer $KOSA8_API_KEY" -H 'Content-Type: application/json' \ -d '{"cmd":["python3","-c","print(6 * 7)"]}' # {"exit":0,"stdout":"42\n","stderr":""} curl -s -X DELETE https://api.kosa8.com/v1/sandboxes/sbx_… \ -H "Authorization: Bearer $KOSA8_API_KEY" ``` Other routes: `GET /v1/sandboxes`, `GET /v1/sandboxes/{id}`, `POST /v1/sandboxes/{id}/sleep` and `/wake`. Errors are JSON with a stable `code`. The ones that stop a create, and what to do: | Answer | Means | Next step | |---|---|---| | **402** `payment_required` | No payment method yet, or this month's usage is past your credit. | In [app.kosa8.com](https://app.kosa8.com/), **Usage** → **Add payment method**. | | **402** `spending_cap_reached` | This month's spend reached the cap you set. | Raise or clear the cap in **Settings**. | | **429** `quota_exceeded` | The organization already has 20 sandboxes running or paused (sleeping ones do not count). | Delete or sleep one, or write to [contact@kosa8.com](mailto:contact@kosa8.com) for a higher limit. | | **503** `no_capacity` | No host has room right now. | Try again shortly. | ## Templates and regions | Template | What is in it | |---|---| | `base` | Alpine Linux (the default) | | `python` | Python 3.12 on Alpine | | `node` | Node.js 22 on Alpine | Sandboxes are kept warm, so creating one takes about a second. They run in the EU (`eu-hel1`, Helsinki) today; more regions follow. ## What it costs Billed per second while a sandbox runs: **$0.048 per vCPU-hour** and **$0.0154 per GB of RAM per hour** in the EU (Helsinki), the only region today; the US and Asia-Pacific will cost 1.2× when they open. An idle sandbox goes to sleep and then costs only **$0.08 per GB-month** for its snapshot; the next command wakes it. Your own hosts (BYOC) cost **$0.20 per host-hour** while registered, with no compute charge. Every kosa8 plan includes Cloud credit each month: **$10** on Solo, **$25** on Pro, **$40 a person** on Team and **$50 a person** on Scale. It pays for Cloud and BYOC usage first, then the one-off $30 signup credit, then the card. With no plan you pay only for what you use. **Usage** in the dashboard shows the month so far, and **Settings** has a spending cap. Plans are compared at [kosa8.com/pricing](https://kosa8.com/pricing/#compare). For scale: a 2 vCPU, 4 GB sandbox costs about $0.16 an hour while it runs, so Solo's $10 credit covers about 63 hours of it, and running it four hours every working day for a month (88 hours) comes to about $13.87. Asleep, a 4 GB snapshot is $0.32 a month. ## The dashboard | Tab | For | |---|---| | Sandboxes | What is running, asleep or stopped, and what each has cost. | | Functions | Deploy a handler and call it; each call runs in its own sandbox. | | API keys | Make and revoke keys. A leaked key: revoke it here first. | | Own hosts | Run sandboxes on your own servers, managed from the same dashboard. | | Team & SSO | Invite people, or connect Okta, Entra ID or Google Workspace. | | Settings / Usage | Default template and region, sleep timer, spending cap, the bill. | | Refer and earn | Your referral link (give $30, get $30) and the affiliate program; see [Refer and earn](https://docs.kosa8.com/guides/refer/). | ## Connect agents and tools - [MCP server](https://docs.kosa8.com/guides/cloud-mcp/): give Claude Code, Cursor or Codex cloud sandboxes with one command, `claude mcp add --transport http --scope user kosa8-cloud https://api.kosa8.com/mcp --header "Authorization: Bearer $KOSA8_API_KEY"`, or `kosa8 cloud mcp`. - [OpenAPI and llms.txt](https://docs.kosa8.com/guides/cloud-openapi/): the API described for code generators and AI agents. - [GitHub Action](https://docs.kosa8.com/guides/cloud-github-action/): run a command in a sandbox from a workflow. - [Webhook recipes](https://docs.kosa8.com/guides/cloud-webhooks/): verify deliveries and forward failures and the spending cap to Slack. ## Cloud or your own machine? kosa8 on your own computer ([Install](https://docs.kosa8.com/guides/install/)) is the same sandbox with no network hop and nothing leaving the machine. Use the cloud when your code runs somewhere without kosa8 installed (a server, a CI job, a hosted agent), or when you want many sandboxes at once without a big machine of your own. --- Source: https://docs.kosa8.com/guides/cloud/ # Refer and earn Every kosa8 Cloud organization has a referral link. When a team signs up with it and makes its first payment of $25 or more, you both get **$30 of kosa8 Cloud credit**. Writers and creators can join the affiliate program instead and earn **20% of payments for 12 months**. ## Find your link - **Dashboard:** [app.kosa8.com](https://app.kosa8.com/app#refer), **Refer and earn** under Account. - **Desktop app:** the **Refer and earn** card on the Account page, once the app is linked to kosa8 Cloud. - **Terminal:** `kosa8 cloud refer` prints the link, what you earned this year and everyone you invited; `--json` for scripts. The link looks like `https://app.kosa8.com/signup?ref=yourcode`. Someone who opens it sees the $30 on the sign-up form. kosa8.com links with `?ref=yourcode` work too: the site remembers the code for 30 days in a first-party cookie and passes it to sign-up and to the licence checkout. ## The referral rules - You and the team you invite each get $30 of kosa8 Cloud credit. - It is earned at the invited team's first payment of at least $25 (a credit pack, a plan or a usage charge). Adding a card only reserves it (**pending**). - The credit becomes spendable 30 days after that payment (**earned**, then **paid**). A refund within those 30 days cancels it; a later refund takes back what is unspent. - A kosa8 licence bought on kosa8.com through your link counts. The buyer's $30 is added when they sign up for kosa8 Cloud, or link the licence, with the same email. - You can earn up to $500 of credit per calendar year; invited teams still get theirs after that. - Each person, payment account and card can be invited once. Invitations from your own email, network, company email domain, billing account or card are **held** and listed with the reason. Public mail domains such as gmail.com are not a company domain. - Referral credit is prepaid kosa8 Cloud credit: it is used after the plan's monthly credit and the signup credit, lasts 12 months from when it becomes spendable, pays for Cloud usage (not licence invoices) and is not cash. ## Affiliate program In **Refer and earn**, open **Affiliate program** and apply with your payout email and where you will promote kosa8 (an owner of the organization applies). kosa8 reviews applications by hand. Once approved, the tab shows your link and its clicks, sign-ups, paying teams, and commission earned, still in the hold, payable and paid out. Add your payout details there too: PayPal or Wise, the account's email, your country and your legal name. - 20% of every payment (before tax) for a kosa8 licence, a kosa8 Cloud plan or Cloud usage from the teams you bring, for 12 months from their sign-up or first licence purchase. Refunds are taken off. - kosa8 Cloud credit packs are prepaid usage and earn no commission. - Commission becomes payable 60 days after the payment, the window in which it can still be refunded. - kosa8 pays monthly, by hand, by PayPal or Wise in US dollars, once at least $50 is payable; less rolls over. - Before the first payout, email kosa8 the tax form the tab names (W-9 in the US, W-8BEN elsewhere). kosa8 never asks for a tax id in the dashboard. Taxes on commission are yours. - A refund after commission was paid is taken off your next payouts. - An affiliate link wins over a referral link. The team still gets its $30; the affiliate earns commission instead of referral credit. - Disclose it wherever you share the link (#ad, or "I earn a commission"), and do not bid on kosa8 brand keywords (kosa8, kosa8 Cloud, or misspellings) in paid search ads. The full terms are at [kosa8.com/terms](https://kosa8.com/terms/#referrals), [kosa8.com/refer](https://kosa8.com/refer/) and [kosa8.com/affiliates](https://kosa8.com/affiliates/). ## For scripts and agents `GET /v1/referral` returns the link, the rules, this year's earnings and the invitation list; `GET /v1/affiliate` the affiliate status and figures; `POST /v1/affiliate/apply` with `{"payout_email": "...", "promote": "..."}` applies; `POST /v1/affiliate/payout` with `{"payout_method": "paypal", "payout_email": "...", "country": "US", "legal_name": "..."}` sets the payout details. All take the organization's API key. --- Source: https://docs.kosa8.com/guides/refer/ # Save and undo An autonomous assistant will eventually do something destructive. The usual answers are to approve every action, which defeats the point, or to rebuild the environment, which throws away everything that made it useful. kosa8 offers a third. ## Why this works at all kosa8 saves memory and running processes, not just files. So a restored machine is not a fresh one that has to be set up again — it is the same machine, mid-task, as it was seconds earlier. A database still holding its connections is still holding them. ### Take a checkpoint before the risky step ``` $ kosa8 checkpoint web --label "before migration" checkpoint web-ckpt-1 (before migration) saved in 2281ms rewind with `kosa8 rewind web` ``` The label is the useful half of the record. "before migration" tells whoever reviews this later why you thought the next step was worth guarding. ### Let it go wrong Anything: a migration that half-applies, a mass delete, a dependency upgrade that breaks the build. It does not matter, because you are about to undo it. ### Rewind ``` $ kosa8 rewind web Rewind web to web-ckpt-1 — before migration? Everything since 2m ago will be lost. [y/N] y web is back at web-ckpt-1 (1065ms) processes resumed from 2m ago ``` About a second, and the sandbox keeps its name — so scripts, agent configuration and your shell history all still point at the right thing. That is the difference between an undo button and "here is a new sandbox, please rewire everything". ## Several checkpoints ``` $ kosa8 checkpoint ls web CHECKPOINT WHEN TAKEN WHY web-ckpt-1 12m ago you before migration web-ckpt-2 4m ago you after the schema change *web-ckpt-3 just now you before the risky bit * is where `kosa8 rewind web` goes ``` Rewind to an earlier one by label or id: ``` $ kosa8 rewind web -y --to "before migration" web is back at web-ckpt-1 (941ms) dropped 2 later checkpoint(s) ``` **Why the later ones are dropped** They describe a future that no longer happened. Keeping them would leave restore points that claim to hold state this machine never reached. ## Things worth knowing - **Ten per sandbox.** Each checkpoint costs a disk clone, so an assistant checkpointing on every step would otherwise fill the disk. The oldest is dropped when you hit the limit. - **Rewinding destroys current state.** It cannot itself be undone, so take a checkpoint first if you might want the current state back. - **Use `-y` to skip the prompt** in scripts, and `--json` for machine-readable output. ## From an AI assistant The same thing over MCP, so an assistant can guard its own work without you typing anything: ``` sandbox_checkpoint save a restore point, with a label saying why sandbox_checkpoint_list what restore points exist sandbox_rewind go back to one ``` **Next** [Try several ideas at once](https://docs.kosa8.com/guides/explore/) — the other thing saving a running machine makes possible. --- Source: https://docs.kosa8.com/guides/undo/ # Try several ideas at once When you do not know which of three fixes is right, the usual approach is to try one, wait, undo, try the next. kosa8 copies the finished machine once per idea and runs them all at the same time. ## Why the copies are cheap The expensive part of any environment is the setup — dependencies installed, a server warm, a cache primed. A fork resumes from that finished state with its processes intact, so four ideas cost roughly what one costs. The setup happened once. ### Do the slow part once ``` $ kosa8 sandbox create web --memory 2048 $ kosa8 sandbox run web node:22 npm ci … 1,412 packages installed ``` ### Try every idea against it ``` $ kosa8 explore web --image node:22 \ --variant "npm test" \ --variant "npm test -- --experimental-vm-modules" \ --variant "npm test -- --babel" forked 3 branches in 6379ms BRANCH RESULT TIME VARIANT web-explore-624461 failed 356ms npm test *web-explore-624462 ok 371ms npm test -- --experimental-vm-modules web-explore-624463 ok 1360ms npm test -- --babel winner: web-explore-624462 other branches removed; --keep-all keeps them for inspection ``` ## What "winner" means The fastest branch that succeeded, not the first. When several approaches work, the quick one is almost always the one worth keeping. The losers are removed automatically, because leaving a dozen live machines behind after every exploration would quietly exhaust the host. ``` # keep them all to look at $ kosa8 explore web --keep-all --variant "…" --variant "…" # keep a specific one regardless of who won $ kosa8 explore web --keep web-explore-624463 --variant "…" --variant "…" ``` ## Just the copies, without running anything ``` $ kosa8 fork web -n 3 --prefix trial forked web into 3 sandboxes in 4497ms trial1 192.168.127.2 trial2 192.168.127.2 trial3 192.168.127.2 ``` Each is independent. Changing one does not touch the others or the parent. **Each branch has a time limit** Two minutes by default, so one stuck idea cannot hold up the answer. Change it with `--timeout`. **Next** [Run an AI assistant](https://docs.kosa8.com/guides/agents/) — give one of these machines to Claude Code or Copilot. --- Source: https://docs.kosa8.com/guides/explore/ # The desktop app Everything running, in one window. Open **kosa8.app** from Applications, or run `kosa8 desk` to get the same dashboard in a browser tab. [image: The kosa8 dashboard showing a lineage panel with a sandbox called api that has two forks and one checkpoint, a table of three running sandboxes with checkpoint, rewind and stop buttons, and panels for containers, snapshots and images.] A real session: one sandbox, two forks of it, and a checkpoint taken before a migration. ## The panel other dashboards do not have **Lineage** is the top panel, and it is the reason this exists. It shows your save points and the copies made from them. Every node is a whole machine that can be brought back with its processes still running — not a rebuilt one. Docker Desktop has no equivalent, because Docker has nothing to put in it: its sandboxes are discarded when the session ends. ## What you can do from it - **Answer a held tool call**. When an agent's call needs a person — a force push, a package publish, anything a [tool-call rule](https://docs.kosa8.com/guides/tool-rules/) says to ask about — it appears at the top under **Waiting for you**, with its arguments in full. **Approve once** lets that exact call run the next time the agent makes it; **refuse** tells it no. The same queue as `kosa8 mcp asks`. - **Pause** a sandbox, or **pause all** from the top bar: the machine freezes where it is, with nothing lost, until you **resume** it. - **Checkpoint** a sandbox — same as `kosa8 checkpoint`, and it asks you why so the save point has a label worth reading later. - **Rewind** to the most recent checkpoint. - **Stop** a sandbox, which removes it and its checkpoints. - **Remove** a snapshot — unless a checkpoint depends on it, in which case kosa8 refuses and says which sandbox needs it. Every action asks first. The page refreshes on its own; untick **live** if you would rather it held still while you read something. ## Why it is safe to leave open This dashboard can create and destroy machines, so two things are true however you start it. ### It only listens to your own computer ``` $ kosa8 desk --addr 0.0.0.0:7444 refusing to serve the dashboard on 0.0.0.0: it can create and destroy containers, so it binds loopback only use 127.0.0.1:7444, and reach it from another machine with ssh -L 7444:127.0.0.1:7444 ``` ### Every run gets a fresh key ``` $ kosa8 desk kosa8 desk on 127.0.0.1:7444 http://127.0.0.1:7444/?t=ZrgVGHu3Q_PnvogItD4QGm1SheKpt_a3m55IeADIc3o the token in that URL is this run only — stop with ctrl-c ``` Requests without it are refused. Without that key, any website you happened to be visiting could drive your containers through localhost — a real attack, not a theoretical one. ## Options ``` $ kosa8 desk --port 7445 # if something else has 7444 $ kosa8 desk --no-open # print the URL, do not open a browser $ kosa8 desk --json # machine-readable url and token ``` **The app and the command are the same thing** kosa8.app is a native window around `kosa8 desk`, not a second implementation. The rules above are written once and apply to both. Quitting the app stops the server it started, so nothing is left listening. --- Source: https://docs.kosa8.com/guides/desk/ # Local models Download a model, serve it, and point your app at it. Your code and prompts never leave the machine, and there is nothing to pay per word. ## What runs the model kosa8 serves models with [llamay](https://www.llamay.com), AZMX's inference engine, when it is installed, and with a smaller engine built into kosa8 when it is not. Either way the command is the same and so is what your app talks to — a model you have pulled can always be served. > Diagram: Your app calls the kosa8 model gateway on 127.0.0.1 port 7434 in the OpenAI or Anthropic dialect. The gateway checks the model against your organisation's approved list, then passes the request to llamay if it is installed, or to kosa8's built-in engine if it is not. Nothing leaves the computer. One address for your app, whichever engine is underneath. **Install llamay for the fast path** On macOS and Linux: `curl -fsSL https://llamay.com/install.sh | sh`. On Windows, use the installer from [llamay.com/download](https://www.llamay.com/download/). `kosa8 doctor` tells you which engine you have. ``` $ kosa8 doctor ✓ local model runtime llamay at /usr/local/bin/llamay ``` ### Pull one Models come from Hugging Face in GGUF form. Name the repository, and the quantisation after a colon if it publishes more than one. ``` $ kosa8 model pull Qwen/Qwen2.5-0.5B-Instruct-GGUF:q4_k_m $ kosa8 model ls NAME SIZE SOURCE PULLED qwen2.5-0.5b-instruct-q4_k_m 468MiB hf.co/Qwen/Qwen2.5-0.5B-Instruct-GGUF 2026-07-29 ``` Give it a shorter name of your own with `--name`. ### Serve it ``` $ kosa8 model serve qwen2.5-0.5b-instruct-q4_k_m loading qwen2.5-0.5b-instruct-q4_k_m… kosa8 model gateway on http://127.0.0.1:7434 OpenAI: POST /v1/chat/completions Anthropic: POST /v1/messages running on llamay on http://127.0.0.1:58965 logs: ~/.kosa8/log/model-qwen2.5-0.5b-instruct-q4_k_m.log ``` It runs until you press Ctrl-C, and takes the model and its port with it when it stops. ### Point something at it kosa8 answers on both of the two dialects that matter, so an app written for either works by changing one address and nothing else. ``` /v1/chat/completions what OpenAI libraries call /v1/completions answered like /v1/chat/completions: send messages, not prompt /v1/models what is available /v1/messages what Anthropic libraries call ``` ``` $ export OPENAI_BASE_URL=http://127.0.0.1:7434/v1 $ export ANTHROPIC_BASE_URL=http://127.0.0.1:7434 ``` ``` $ curl -s http://127.0.0.1:7434/v1/chat/completions \ -H 'Content-Type: application/json' \ -d '{"model":"anything","messages":[{"role":"user","content":"Reply with the single word: ready"}],"max_tokens":8}' {"choices":[{"finish_reason":"stop","index":0,"message":{"role":"assistant","content":"ready"}}], …} ``` The gateway serves exactly one model, so it answers whatever name your app sends in `model`. You do not have to change your app's model setting to match. ## Choosing the engine `--backend` decides which engine serves. The default, `auto`, uses llamay when it is on your PATH and the built-in engine otherwise, and tells you when the built-in one was chosen. | --backend | What it does | If llamay is missing | |---|---|---| | `auto` | llamay if installed, else the built-in engine. | Serves with the built-in engine and says so. | | `llamay` | Requires llamay. | Refuses to start and tells you how to install it. | | `engine` | Always the built-in engine. | No difference. | What you get from each: | | llamay | Built-in engine | |---|---|---| | Needs installing | Yes, once | No | | GPU | Yes, where the machine has one | CPU | | Streaming | yes | yes | | `temperature` honoured | yes | no | | Token probabilities (`logprobs`) | yes | no | | Tool calling (`tools`) | yes | yes | | Can drive the tool-call classifier | yes | no — it never auto-allows | ## Token probabilities Ask for `logprobs` and the gateway passes the request to llamay and the answer back: for each token, how likely the model thought it and its nearest alternatives were. This is what lets a program treat an answer as a measurement rather than a string. ``` $ curl -s http://127.0.0.1:7434/v1/chat/completions -H 'Content-Type: application/json' -d '{ "messages":[{"role":"user","content":"Answer with exactly one letter, A or B. …"}], "max_tokens":1, "temperature":0, "logprobs":true, "top_logprobs":5 }' # choices[0].logprobs.content[0].top_logprobs, as probabilities: "B" 0.7968 "A" 0.2030 "No" 0.0001 ``` kosa8 uses exactly this for the optional classifier that decides whether a sensitive tool call needs a person: see [Rules for tool calls](https://docs.kosa8.com/guides/tool-rules/). Token probabilities are returned for ordinary requests, not streamed ones. ## Tool calling Send `tools` on either endpoint and the model can call them. The gateway answers with OpenAI `tool_calls` or Anthropic `tool_use` blocks, streamed or not, and takes the results back as `tool` messages or `tool_result` blocks, so agent frameworks and coding agents that work through tools can run on a local model. `tool_choice` is honoured: `auto`, `none`, `required` (Anthropic: `any`), or one named function. ``` $ curl -s http://127.0.0.1:7434/v1/chat/completions -H 'Content-Type: application/json' -d '{ "messages":[{"role":"user","content":"What is the weather in Paris?"}], "tools":[{"type":"function","function":{"name":"get_weather", "parameters":{"type":"object","properties":{"city":{"type":"string"}}}}}], "tool_choice":"required" }' {"choices":[{"finish_reason":"tool_calls","message":{"content":null,"tool_calls":[ {"id":"call_…","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"Paris\"}"}}]}}], …} ``` The gateway offers the tools to the model in the Hermes format, the one Qwen2.5, Qwen3 and most open tool-calling models were trained on, and reads calls back out of the answer. A model that was not trained on that format will answer in text, and you get the text: a call is only reported when the model wrote one that parses. Small models are reluctant to call on their own; a 7B model or larger, or `tool_choice`, works better. Images in messages are dropped. ## Sharing one with your team A model is several gigabytes. Having everyone download it separately is slow, and on a locked-down network may not be allowed at all. kosa8 can publish a model into the same registry your company already uses for container images. ``` $ kosa8 model push qwen2.5-0.5b-instruct-q4_k_m registry.company.com/models/qwen:v1 $ kosa8 model pull-oci registry.company.com/models/qwen:v1 --name qwen $ kosa8 model serve qwen ``` It is stored as a standard OCI artifact, so a registry that holds container images can hold this too — no special model server, nothing new for anyone to approve. The fingerprint is checked as it downloads; a damaged or altered file is refused rather than quietly kept. ## Which models may be served An organisation can list the models it has approved, by the fingerprint of the weights, in its signed policy. kosa8 then refuses to serve anything else — a matching name is not enough. `kosa8 model check` tells you where a model stands before you try. ``` $ kosa8 model check qwen2.5-0.5b-instruct-q4_k_m qwen2.5-0.5b-instruct-q4_k_m digest sha256:74a4da8c9fdbcd15bd1f6d01d621410d31c6fc00986f5eb687824e7b93d7a9db signature unsigned policy corp has no approved model list: any model may be served recording off ``` The policy can also ask for every request to be recorded, as fingerprints or in full. See [Policy reference](https://docs.kosa8.com/guides/policy-reference/) for `allowed_models` and `model_recording`. ## Using it with the assistant ``` $ kosa8 ai --provider local -m "what is using the most memory?" ``` With no `ANTHROPIC_API_KEY` set, `kosa8 ai` uses the local model gateway by default, so nothing is sent anywhere. #### What this does not do - The gateway answers callers on this machine only. It has no login of its own: any program running as you can use the model while it is being served. - It serves one model at a time, on one fixed port. Serving a second means stopping the first. - A small model is not a large one. Local models are private and free to run; they are not a drop-in replacement for the largest hosted ones on hard tasks. ## Where to go next [`Rules for tool calls` Let a local model decide which sensitive tool calls need a person.](https://docs.kosa8.com/guides/tool-rules/) [`Policy reference` Approved model lists and recording, in the signed policy.](https://docs.kosa8.com/guides/policy-reference/) [`kosa8 model serve` Every flag, from the binary itself.](https://docs.kosa8.com/reference/model-serve/) --- Source: https://docs.kosa8.com/guides/models/ # When something is wrong Run `kosa8 doctor` first. It checks the things that actually stop kosa8 working, and for each failure it says what it observed, why that matters, and what fixes it. ``` $ kosa8 doctor ✓ platform darwin/arm64 ✓ hypervisor support kern.hv_support=1 ✓ kosa8d daemon reachable ✗ one daemon only 2 kosa8d processes: 15022, 36328 why it matters: two daemons cannot share the engine disk, and the second one to want it fails with an error that does not mention the first → inspect with `ps -p 15022,36328`, then stop the one you do not want ✓ guest kernel and rootfs present ✓ state directory is private 0700 ``` ## Letting it repair things `kosa8 doctor --fix` repairs the failures that are safe to repair unattended and tells you exactly what it did. ``` $ kosa8 doctor --fix repaired: ✓ removed the stale socket file; `kosa8 daemon start` will work now · inspect with `ps -p 15022,36328` … (there is no automatic fix for this) · kosa8 run --rm alpine true (there is no automatic fix for this) ✓ created ~/.kosa8/log ✓ restricted ~/.kosa8 to you only ``` `--dry-run` shows what `--fix` would do and changes nothing. ## What it will not do This is not an agent that fixes everything, on purpose. A repair loop that acts on a failure it does not recognise turns a small problem into a large one — the obvious remedy for "another daemon is holding the disk" is to kill it, and the daemon it kills might be the one doing your real work. So every repair carries a risk class, the same ones the [approval gate](https://docs.kosa8.com/guides/agents/) uses, and only the safe ones are ever applied for you: | Class | Example | Applied by `--fix`? | |---|---|---| | read-only | Reading a permission bit | Yes | | contained | Removing a socket nothing is listening on; creating a missing log directory; tightening permissions on your own state directory | Yes | | destructive | Stopping a daemon; removing a machine or a snapshot | **Never** | | escalating | Downloading guest images; re-signing a binary | **Never** | No flag changes that. It is the same reasoning as the approval gate: a switch that turns off every safeguard is not a safeguard. Destructive and escalating failures are printed as a command for you to run, with enough context to decide whether you want to. **A result of "could not tell" is not a failure** If a check cannot read the state it needs, it says so and nothing is repaired. Acting on a state you could not read is exactly how a repair tool causes the damage it exists to prevent. ## Prevention beats repair The duplicate-daemon failure above is what prompted all of this, and it is deliberately *not* in the repair list. kosa8d now takes an exclusive lock on its state directory, so a second daemon refuses to start and names the one already running. A problem that cannot happen needs no healer. --- Source: https://docs.kosa8.com/guides/self-repair/ # Let your agent set kosa8 up You don't have to read the install guide. Ask the coding agent you already use to do it. kosa8 publishes its setup as a page written for agents, and the agent asks you for the one thing it needs: you, signing in. It runs `kosa8 login` and hands you a short code and a link; you sign in or create your account in your browser and confirm the code. (Or, without a browser, it asks for your email and the code we send to it.) ## Ask your agent Paste this into Claude Code, Codex, Cursor or any agent that can run commands: ``` Set up kosa8 for me by following https://kosa8.com/AGENTS.md ``` The agent will: 1. check whether kosa8 is installed, and install it if not, telling you first; 2. run `kosa8 doctor` and fix what it reports, or tell you what it cannot fix; 3. run `kosa8 login` and give you the code and link it prints, then wait while you confirm it in your browser; 4. or, if you prefer, ask you which email to use, send a code with `kosa8 signup`, and ask you for it; 5. start the daemon, and run a first task with [`kosa8 prompt`](https://docs.kosa8.com/guides/prompt/). The page tells the agent never to invent an email address, never to read your inbox for the code, and never to work around a refusal. Signing up puts your machine on the free tier (sandboxes, save and undo, and the dashboard) and subscribes you to the weekly newsletter, which has a one-click unsubscribe. Forks, local models and the other paid products need a plan, $30 off your first payment. ## Give the agent kosa8's tools To have the agent run its own code in sandboxes, not only hand tasks to another agent, add the tools and the guidance that goes with them. See [Connect Claude, Cursor or Codex](https://docs.kosa8.com/guides/connect-agents/). ``` $ kosa8 mcp install $ kosa8 skill install ``` ## Docs an agent can read Every page on this site is also published as markdown, which costs an agent far fewer tokens than the HTML. Add `.md` to a page's address: - [docs.kosa8.com/guides/prompt.md](https://docs.kosa8.com/guides/prompt.md) - [docs.kosa8.com/reference/sandbox-create.md](https://docs.kosa8.com/reference/sandbox-create.md) - [llms.txt](https://docs.kosa8.com/llms.txt): an index of every page - [llms-full.txt](https://docs.kosa8.com/llms-full.txt): the whole manual in one file --- Source: https://docs.kosa8.com/guides/agent-setup/ # One task, one sandbox `kosa8 prompt` is the shortest way to use kosa8. You give it a task. It starts a fresh sandbox, runs a coding agent on the task with no one at the keyboard, prints what the agent says, and removes the sandbox when the agent is done. ``` $ kosa8 prompt "add a test for parseConfig and make it pass" kosa8: kit claude (node:22-slim) kosa8: workspace /Users/you/src/app -> /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 (the sandbox holds a placeholder, never the key) kosa8: sandbox claude-41822 ready in 690ms ...the agent works... kosa8: claude finished in 2m14s (sandbox claude-41822 removed; what it did: `kosa8 audit ls`) ``` ## What the agent can and cannot do It is the same sandbox [`kosa8 agent run`](https://docs.kosa8.com/guides/agents/) makes, so the same limits hold: - **One folder.** The current directory, or the one you name with `-w`, appears at `/work`. The agent can change files there, and you see the changes on your machine. Nothing else on the host is visible. - **Listed hosts only.** The network is the kit's allow-list. `--no-network` closes it completely. - **No API key.** Your key stays on your machine, in kosa8's egress broker. The sandbox gets a placeholder that is worthless anywhere else. - **Checked tool calls.** With the claude kit, each tool call is checked against your [rules](https://docs.kosa8.com/guides/tool-rules/) before it runs. Calls held for a person show up in `kosa8 mcp asks`. - **A record.** Everything lands in the hash-chained [audit log](https://docs.kosa8.com/guides/audit/). Inside that boundary the agent runs with its own permission prompts off, because there is no one to answer them. That is what the sandbox is for: the agent can do anything to the machine it is in, and nothing to yours. ## Choosing the agent | Flag | Agent | Key it needs on your machine | |---|---|---| | `-a claude` (default) | Claude Code | `CLAUDE_CODE_OAUTH_TOKEN` (your Claude Pro or Max plan, from `claude setup-token`), or `ANTHROPIC_API_KEY` | | `-a codex` | OpenAI Codex CLI | `OPENAI_API_KEY` | | `-a gemini` | Gemini CLI | `GEMINI_API_KEY` | | `-a opencode` | OpenCode | `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` | | `-a copilot` | GitHub Copilot CLI | `GITHUB_TOKEN` (passed in: Copilot cannot use the broker) | | `-a shell` | A plain shell; the task is a shell command | none, and no network | Set `KOSA8_AGENT` to change the default. With the claude kit, a subscription token is used when one is set, and the API key otherwise. Either way it stays on your machine in the broker: the sandbox only ever holds a placeholder. Make the token in a terminal of your own (it is shown once), then add `export CLAUDE_CODE_OAUTH_TOKEN=...` to your shell profile. ## Giving it the task ``` $ kosa8 prompt -w ~/src/api "why does TestLogin flake? fix it if you can" $ git diff main | kosa8 prompt -a codex - $ kosa8 prompt --dry-run "upgrade lodash" # shows the plan, creates nothing ``` The task is the rest of the command line. With `-`, or with nothing and input piped in, it is read from standard input. Put flags before the task: everything after the first word that is not a flag is the task. Large material belongs in the workspace, with the task saying where it is. The task itself is limited to 96 KiB. ## Afterwards The exit status is the agent's, so `kosa8 prompt` works in scripts and CI. To look inside the sandbox when it is done, pass `--keep`. It stays until you remove it with `kosa8 sandbox rm NAME`. ## Your own agent Any kit can take a task once it says how. Add a `prompt` line to it, with `{{prompt}}` where the task goes: ``` $ kosa8 agent init claude # copy the built-in to ~/.kosa8/kits/claude.yaml # then, in the file: prompt: ["claude", "-p", "{{prompt}}", "--permission-mode", "bypassPermissions"] prompt_env: IS_SANDBOX: "1" ``` Each item is passed as one argument, so the task is never read as shell. A kit without a `prompt` line can still be run interactively with `kosa8 agent run`. See [Writing a kit](https://docs.kosa8.com/guides/kits/). --- Source: https://docs.kosa8.com/guides/prompt/ # Connect Claude, Cursor or Codex Two commands give the AI coding agent you already use a sealed sandbox to work in. The first adds kosa8's tools to the agent. The second teaches the agent when to use them. ``` $ kosa8 mcp install Claude Desktop registered in ~/Library/Application Support/Claude/claude_desktop_config.json Cursor registered in ~/.cursor/mcp.json Claude Code registered for every project (claude mcp add --scope user) $ kosa8 skill install claude-code ~/.claude/skills/kosa8/SKILL.md codex ~/.codex/skills/kosa8/SKILL.md ``` Restart the client, then ask it something like *"clone this repo into a kosa8 sandbox and run its tests"*. ## In plain words An AI agent that runs code on your computer can do anything you can: delete files, read your SSH keys, or send your API keys to someone else. With kosa8 connected, the agent gets its own small, sealed machine for that work. It can install packages, build, run tests and start web servers in there, and your files and network stay out of reach unless you allow them. - **The tools** (`kosa8 mcp install`) are what the agent can do: create a sandbox, run shell commands and code in it, read and write its files, use git, take checkpoints and rewind, and make a link to a web app running in it. - **The skill** (`kosa8 skill install`) is a short page of guidance the agent reads. It says when to reach for a sandbox (untrusted code, installs, builds, dev servers, anything risky) and how to use the tools well, including cleaning up afterwards. ## kosa8 mcp install With no arguments it sets up every client it finds. To choose, name them: ``` $ kosa8 mcp install cursor windsurf $ kosa8 mcp install --print # show what would be added, change nothing $ kosa8 mcp install --uninstall # take kosa8 out again ``` | Client | Where it is registered | |---|---| | `claude-desktop` | `claude_desktop_config.json` in Claude's application-support folder | | `claude-code` | Through Claude Code's own `claude mcp add --scope user`, so every project has it | | `cursor` | `~/.cursor/mcp.json` | | `windsurf` | `~/.codeium/windsurf/mcp_config.json` | | `codex` | `~/.codex/config.toml`, as `[mcp_servers.kosa8]` | Each file keeps every other server and setting in it. The file as it was is saved next to it as `.bak`, and running the command again changes nothing. The entry starts `kosa8 mcp serve`, which talks to the kosa8 daemon on your machine, so start it first with `kosa8 daemon start`. The tools it exposes are listed in [Tool servers (MCP)](https://docs.kosa8.com/guides/mcp-servers/), and every call goes through the same policy, approvals and audit log as the CLI. ## kosa8 skill install | Agent | Where the skill goes | |---|---| | `claude-code` | `~/.claude/skills/kosa8/SKILL.md`, a personal skill for every project | | `codex` | `~/.codex/skills/kosa8/SKILL.md` | | `cursor` | `.cursor/rules/kosa8.mdc` in a project (`--project DIR`, default the current folder) | With no arguments it installs for Claude Code and Codex. A file at that path that kosa8 didn't write is left alone unless you pass `--force`. `kosa8 skill show` prints the skill, so you can read exactly what the agent is told or paste it into another agent's instructions. ## What the agent does with it 1. Creates a sandbox with `sandbox_create`: no network unless the task needs it, set to stop itself after 30 idle minutes, and from your template if you have one (`kosa8 template build`). 2. Works in it with `sandbox_shell` (a shell that remembers `cd` and `export`), `sandbox_code_run`, the file tools and `sandbox_git`. 3. Takes a checkpoint before a risky step, and rewinds if it breaks something. 4. Shows you a web app with `sandbox_preview`, which gives a link that expires. 5. Deletes the sandbox when it's done. You can watch along at any time with `kosa8 sandbox ls`, `kosa8 audit` and the Mac app. --- Source: https://docs.kosa8.com/guides/connect-agents/ # Run an AI assistant kosa8 ships ready-made setups, called kits, for the common coding assistants. Each runs in a virtual machine of its own, sees only the folder you hand it, reaches only the hosts it needs — and works with your API key without ever being given it. > Diagram: On your computer, kosa8 keeps your API key and your other files. In a separate virtual machine, the assistant sees one shared folder and holds a placeholder instead of the key. Its requests go out through a broker on your computer, which adds the real key for the model provider only and refuses everything not on the kit's list. The key and the decisions stay on your side. The assistant gets a folder and a placeholder. 1. You choose one folder. Nothing else on your computer exists as far as the assistant is concerned. 2. The assistant starts in its own virtual machine with a stand-in where the API key would be. 3. Its only way out is a broker running on your computer, which holds the real key. 4. A request to the model provider goes out with the real key added on the way. 5. A request anywhere else is refused before it leaves your computer, and recorded. ## What is ready to go ``` $ 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-slim 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 ``` The **KEYS** column is the one to read. *Brokered* means the assistant never holds your key. *Passed in* means it does: the Copilot CLI cannot be pointed at a different address, so its token still has to go inside, and kosa8 says so every time you run it. Two kits, `cursor` and `azmx`, are listed as unavailable until their installers can be verified — [Writing a kit](https://docs.kosa8.com/guides/kits/) explains why. ### Set your key, on your computer ``` $ export ANTHROPIC_API_KEY=sk-ant-… ``` This is the only place it needs to be. kosa8 reads it when you start the assistant and hands it to the broker, not to the sandbox. ### Run one in your project ``` $ cd ~/code/api $ kosa8 agent run claude kosa8: kit claude (node:22-slim) 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 ``` Read the four lines at the top. They are kosa8 telling you, before anything runs, exactly what this assistant will be able to see, reach and use. ### Or point it at another folder ``` $ kosa8 agent run -w ~/code/web claude ``` Options go before the kit's name; anything after it is passed to the assistant itself. `--dry-run` prints the plan and stops without starting anything. ### When it ends, it is gone ``` kosa8: sandbox claude-44059 removed ``` The sandbox is removed when the assistant exits — cleanly, with an error, or because you pressed Ctrl-C — and the broker and the key it held go with it. Your project folder keeps whatever the assistant changed. Use `--keep` to keep the machine for a look around. ## What the assistant can and cannot do | It can | It cannot | |---|---| | Read and change the folder you shared. | See any other file on your computer. | | Call its model provider, through the broker. | Read your API key, or send it anywhere. | | Reach the hosts its kit lists, by name. | Reach any other host, any address directly, or any service on your own computer. | | Install the exact package version its kit pins. | Install a different one: the download is checked against a fingerprint first. | | Run as root inside its own machine. | Change any of the above from in there. The rules live on your side. | **The shared folder is shared** The assistant can change anything in the folder you give it, including `.git` and any `.env` file in it. Share a project, not your home directory, and take a [checkpoint](https://docs.kosa8.com/guides/undo/) or a commit first. ## See what it did Every request the assistant made through the broker — allowed or refused — is in the audit log, along with every command kosa8 ran for it. So is every tool call the model told it to make: kosa8 reads them from the model's own reply on your side of the sandbox, so the assistant cannot leave one out. The log keeps each call's name and a fingerprint of its arguments, not the arguments. ``` $ kosa8 audit ls 21 02:16:29 guest sandbox.egress.allow claude-44059 200 22 02:16:29 guest sandbox.tool_call claude-44059 200 23 02:16:31 guest sandbox.egress.deny claude-44059 403 $ kosa8 audit verify audit chain intact: 23 entries verified ``` [The audit log](https://docs.kosa8.com/guides/audit/) shows how to read these and how to ask it questions. ## Checked before it runs With the `claude` kit, every tool call is checked against kosa8's [rules for tool calls](https://docs.kosa8.com/guides/tool-rules/) before it runs, not only recorded after. A download piped into a shell, or an edit to the assistant's own settings, is refused. A force push, a package publish or a `terraform destroy` waits for you: ``` $ kosa8 mcp asks 9c41e07b2d5a6f18 pending sandbox:claude-44059/Bash by (unidentified agent) why: K8-A001: rewrites or deletes history on a remote, which a rewind here cannot undo args: {"command":"git push --force origin main"} until 14:32:10 $ kosa8 mcp approve 9c41e07b2d5a6f18 ``` The assistant is told the call is waiting and tries it again once you approve; it runs once, exactly as approved. If the check cannot be made — the broker is gone, or the assistant has broken the hook — the call is refused, and a call the model asked for that was never checked is written to the audit log as `sandbox.tool_call.unchecked`. ## Stop everything, now ``` $ kosa8 sandbox pause --all paused claude-44059, codex-1187 ``` Every running sandbox freezes where it is — the assistants stop mid-step, with their memory and files intact for you to look at. `kosa8 sandbox resume NAME` lets one carry on. ## Letting an assistant drive kosa8 itself kosa8 is also an MCP server: an assistant running anywhere can ask it to make sandboxes, take checkpoints and fork machines, without you typing the commands. ``` $ kosa8 mcp serve # stdio, for a local client $ kosa8 mcp serve --http 127.0.0.1:7433 --allow-mount ~/work # prints a token; send it as a Bearer header $ kosa8 mcp tools # everything it exposes ``` A sandbox an assistant creates this way has no network unless it asks for specific hosts, and can share a folder only from the directories you allowed with `--allow-mount`. [`Tool servers (MCP)` Run third-party tool servers sealed off, with their definitions pinned.](https://docs.kosa8.com/guides/mcp-servers/) [`Rules for tool calls` Decide a call by what it asks for, and hold the risky ones for a person.](https://docs.kosa8.com/guides/tool-rules/) [`Agent identity` Give each agent a name the audit log can trust and rules of its own.](https://docs.kosa8.com/guides/agent-identity/) [`MCP from TypeScript` kosa8's tools in the AI SDK, LangChain.js and Mastra, tested.](https://docs.kosa8.com/guides/js-recipes/#mcp) ## Building your own agent If you are writing the agent rather than running one, give it a sandbox from code. With the Vercel AI SDK, `@kosa8/ai-sdk-sandbox` hands your tools a sealed machine to read, write and run in; with anything else, `@kosa8/sdk` drives sandboxes, the broker, checkpoints and forks directly. [`Vercel AI SDK adapter` An AI SDK agent whose files and commands live in a microVM.](https://docs.kosa8.com/guides/js-ai-sdk/) [`JavaScript and TypeScript` The SDK, the adapter, MCP and the Docker API: which to use.](https://docs.kosa8.com/guides/js-overview/) ## The built-in assistant kosa8 also has its own, which sees your live container state and runs every proposed action through an approval gate. ``` $ kosa8 ai -m "why did the api container exit?" ``` **Some things can never be waved through** You can let ordinary work run without prompting, but destructive and escalating actions are always confirmed — whatever you put in the settings. A switch that turns off every safeguard is not a safeguard. ## Where to go next [`Securing an agent` Every layer between an assistant and what it could damage, and what each does not do.](https://docs.kosa8.com/guides/secure-agents/) [`Keep API keys out` How the broker works, and how to broker your own tool's key.](https://docs.kosa8.com/guides/credentials/) [`Writing a kit` Describe your own assistant's environment: image, packages, hosts, keys.](https://docs.kosa8.com/guides/kits/) [`Agent troubleshooting` Every refusal message, what it means and what to do.](https://docs.kosa8.com/guides/troubleshooting/) --- Source: https://docs.kosa8.com/guides/agents/ # Securing an agent A sandbox stops an AI agent from touching your computer. It does not stop the agent from misusing the things you gave it on purpose: an API key, a network connection, a set of tools. This page is the map of what kosa8 puts between an agent and each of those, which threat each layer answers, and where each one stops. ## Why a sandbox alone is not enough A kosa8 sandbox is a virtual machine with its own kernel. An agent inside one cannot read your files or reach your processes. Suppose that wall is perfect. The agent can still go wrong in five ways, because none of them involve getting through the wall. - **It can leak its key.** An agent needs an API key to work. If the key is an environment variable in the sandbox, the agent can read it, and so can every package it installs and every script it is talked into running. Anything that can read a key can send it somewhere. - **It can say anything to a host it is allowed to reach.** A rule that says "this sandbox may reach `api.example.com`" is, underneath, a rule about addresses. It says nothing about which request is made, on which port, or what is uploaded. - **A tool can give it new instructions.** A tool server describes each tool in text the model reads. A server can change that text after you approved the tool, and the model will follow the new version. - **It can call a permitted tool with harmful arguments.** "May call `create_issue`" does not say in which repository. "May call `run_query`" does not say which query. - **It looks like every other agent.** If three agents share a machine and one misbehaves, a log that says "an agent did it" tells you nothing. > Diagram: Inside a perfectly sealed virtual machine sit an agent and a real API key that anything in the machine can read. The key and any data can be sent to a host the sandbox is allowed to reach. The same address answers for other sites, which address rules cannot tell apart. A tool server sends new instructions back into the agent. Nothing here crosses the wall of the virtual machine. Every arrow uses something the agent was given. 1. The key is in the sandbox, so whatever runs in the sandbox can read it. 2. A host you allowed will accept any request the agent sends it, with the key or your files in it. 3. One address often serves many sites. A rule about addresses admits all of them. 4. A tool's description is text from a stranger that the model treats as instructions. None of this is a flaw in the virtual machine. It is the difference between isolating a program and governing what it does with what it holds. The rest of this page is about the second. ## The layers kosa8 answers each of those with a separate control, and all of them except the first run on your side of the boundary (the guard and broker in the `kosa8d` service, the tool checks in the `kosa8 mcp serve` gateway), where nothing inside the sandbox can reach them. That placement is the point: a rule the agent could edit is a suggestion. > Diagram: An agent runs in a sandbox virtual machine, the first layer. Its action then passes, on your computer, the network guard and egress broker, the check that a tool's definition is pinned and its arguments fit the schema, the policy rules, and for some calls a person's approval. Every decision, allowed or refused, is written to the audit log. An action refused at any layer stops there. Six layers. Only the first is inside the sandbox; the rest are out of the agent's reach. 1. The agent runs in a virtual machine of its own. It sees one folder and no key. 2. Anything it sends to the network passes the guard, then the broker, which admits hosts by name and adds your key on the way out. 3. A tool is offered only while its definition is the one that was approved, and a call's arguments must fit the approved schema. 4. Your organisation's signed rules decide the call by its arguments: allow, deny, or ask. 5. A call a rule answers "ask" waits for a person. The approval covers exactly that call, once. 6. Each decision is written to a log the agent cannot edit. 7. A refusal at any layer ends the action there. 8. An allowed action is recorded as well, so the log answers "what did it do", not only "what was stopped". **Not every action meets every layer** The picture shows the layers in the order an action could meet them. Which ones apply depends on the action. Network traffic from a sandbox meets layers 1 and 2. A tool call made through kosa8's MCP gateway meets layers 3, 4 and 5. (MCP, the Model Context Protocol, is how an AI client is given tools.) Both are recorded in layer 6. **sandbox VM**A virtual machine per agent, with its own kernel, disk and private network. The agent sees the folder you named and nothing else on your computer. Start with [Your first sandbox](https://docs.kosa8.com/guides/first-sandbox/); the picture of what runs where is in [How kosa8 works](https://docs.kosa8.com/guides/how-it-works/). **network guard**Every packet the virtual machine sends is read on your computer before anything forwards it. A sandbox can be given no network, or a list of hosts. See [Network rules](https://docs.kosa8.com/guides/network/). **egress broker**The one door a brokered sandbox's traffic leaves through. It admits hosts by name and port, and it holds your API key so the sandbox does not: the agent gets a placeholder, and the real key is added on your side. See [Keep API keys out of the sandbox](https://docs.kosa8.com/guides/credentials/). **pinned tools**The gateway records a digest of each tool's definition the first time it sees a server. A tool whose definition later changes, and a tool that newly appears, is withheld until a person accepts it. Arguments and results are checked against the pinned schemas. See [Tool servers](https://docs.kosa8.com/guides/mcp-servers/). **policy rules**Rules in your organisation's signed MCP policy decide a call by what it asks for, not only by the tool's name: "issues go in `acme/*` repositories", "merging needs a person". Deny beats ask beats allow. See [Tool rules](https://docs.kosa8.com/guides/tool-rules/). **ask a person**A call a rule answers "ask" is refused with an id. `kosa8 mcp asks` shows it; `kosa8 mcp approve ID` approves exactly that call, for one use, for ten minutes. See [Tool rules](https://docs.kosa8.com/guides/tool-rules/). **agent identity**Each agent can be given a name, a scope and a token the service verifies. Policy is then decided in that agent's scope, and its log entries carry its name. See [Agent identity](https://docs.kosa8.com/guides/agent-identity/). **pinned packages**A kit (the recipe an agent sandbox is built from) pins what it installs to an exact version and the SHA-512 of that version's tarball, and a kit file can be signed. See [Kits](https://docs.kosa8.com/guides/kits/). **audit log**A hash-chained record of every action: brokered requests allowed and refused, commands run, tool decisions. `kosa8 audit verify` proves nobody edited it. See [The audit log](https://docs.kosa8.com/guides/audit/). ## Threats and what answers them Read the last column before the first two. "Partly" is the honest answer more often than "yes". | Threat | What answers it | How far | |---|---|---| | Prompt injection sends your data out | The sandbox starts with no network, or a short list of named hosts. Everything else is refused on your computer and logged. See [Network rules](https://docs.kosa8.com/guides/network/). | partly The agent can still send anything it can read to a host you allowed. Request bodies are not inspected. Allow few hosts, and none that accept uploads. | | Stolen API key | The egress broker keeps the key on your computer. The sandbox holds a placeholder that is worthless anywhere else. See [Keep API keys out of the sandbox](https://docs.kosa8.com/guides/credentials/). | yes for the brokered kits: the key cannot be read from inside. partly for spending: while the sandbox runs, the placeholder buys whatever the route allows. The `copilot` kit still passes its token in. | | Tool poisoning, or a "rug pull" | Tool definitions are pinned by digest. A changed or new definition is withheld until a person reviews it with `kosa8 mcp pins diff`. See [Tool servers](https://docs.kosa8.com/guides/mcp-servers/). | partly The first definition a server gives is taken on trust, unless your organisation's policy lists the pins. Text a tool *returns* is checked for shape, not for hidden instructions. | | Over-broad tool use | Argument rules: a call is allowed, denied or held for a person by what its arguments say. They apply to kosa8's own tools too. See [Tool rules](https://docs.kosa8.com/guides/tool-rules/). | yes for what you wrote a rule for. A rule you did not write protects nothing; kosa8 does not guess which calls are dangerous. | | Malicious package in the agent's install | Built-in kits install one pinned version, checked against its SHA-512 before anything runs. A kit whose setup downloads anything else is refused unless you pass `--allow-unpinned`. See [Kits](https://docs.kosa8.com/guides/kits/). | partly The pin covers the package's own tarball. Its dependencies are resolved by npm at install time, and images are still named by tag. | | An agent pretending to be another | An identity the service verifies: a token minted with `kosa8 agent token create`. A bad, expired or revoked token is refused, not served anonymously. See [Agent identity](https://docs.kosa8.com/guides/agent-identity/). | partly Tokens are bearer tokens: whoever holds one is that agent. A process running as you on your computer can still call the service as a person. | | "What did it do?" | The audit log records brokered requests (host, port, method, path, status), every command with the SHA-256 of its exact arguments, and tool decisions. See [The audit log](https://docs.kosa8.com/guides/audit/). | partly Never bodies, never query strings, never the environment. Inside an encrypted tunnel only the host, port and byte counts are known. Entries are rate-limited, with a count of any held back. | ## Get the essentials in five minutes Three things are worth seeing with your own eyes: the key is not in the sandbox, a request that should be refused is refused, and both facts are written down. ### Run a brokered kit With your key in your shell's environment as usual, start a built-in kit. Flags go before the kit name. ``` $ kosa8 agent run -w ~/code/api claude kosa8: kit claude (node:22-slim) 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 ``` Those lines are the contract for this run: which folder is visible, which hosts are reachable, and where the key is. To read them without starting anything, add `--dry-run`. ### See that the key is not inside Ask the agent to print its environment. Where the key used to be there is a placeholder, and the API's address has been replaced by an address on the sandbox's own private network. This is from a test kit with one brokered credential named `echo`: ``` ECHO_KEY=kosa8-placeholder-1803c857986afe6bc9f59600e4d6f6f8 ECHO_URL=http://192.168.127.254:57504/echo HTTPS_PROXY=http://192.168.127.254:57504 ``` In the `claude` kit the same two values arrive as `ANTHROPIC_API_KEY` and `ANTHROPIC_BASE_URL`. A request sent to that address with the placeholder reaches the real API with the real key, which was added on your computer. The same placeholder sent anywhere else is a random string. ### See a refused request in the audit log From inside that test sandbox, a request to a host that was not listed came back `HTTP/1.1 403 Forbidden`, and a connection that tried to go around the broker altogether got `Connection refused`. On your side, both are in the log: ``` $ kosa8 audit ls --json # the action and detail of three of the entries sandbox.egress.allow {"kind":"route","host":"httpbin.org","port":443,"method":"GET","path":"/headers","route":"echo","status":200,"bytes_in":225} sandbox.egress.deny {"kind":"http","host":"neverssl.com","port":80,"method":"GET","path":"/","reason":"host not in allow-list"} sandbox.egress.deny {"kind":"connect","host":"evil.example","port":443,"reason":"host not in allow-list"} $ kosa8 audit verify audit chain intact: 17 entries verified ``` The first line is the allowed request. It names the route and the path, and it does not contain the key. `kosa8 audit verify` walks the chain and tells you whether any entry was altered or removed. ## For an organisation Every layer above works on one person's computer with no setup. An organisation can go further and make them mandatory with a signed policy that a developer's machine enforces and cannot weaken: - `require_sandbox` refuses containers outside a sandbox. - `default_egress` is applied to any sandbox created without a network rule of its own. - `allowed_registries` is checked wherever an image is fetched. - `allowed_mcp` is checked when a tool server is registered and again when it starts. - `require_agent_identity` refuses agent requests that carry no token. - Where a signing key is pinned, a kit file runs only if it is signed by that key (`kosa8 agent sign`). See [Governance](https://docs.kosa8.com/guides/governance/) and the [Policy reference](https://docs.kosa8.com/guides/policy-reference/). ## What kosa8 does not protect against A control believed to do more than it does is worse than none. These are the edges, stated plainly. #### What this does not do - **The placeholder is a spendable capability.** The agent cannot read your key, and it can still use it: any request the route's methods and paths admit is made with the real credential, for as long as the sandbox runs. The broker cannot tell a good request from a bad one. Narrow the route, and scope the key itself at the provider. - **An encrypted tunnel is opaque.** For HTTPS to an allowed host the broker sees a name and a port, then TLS it does not open. There is no method or path filtering inside it, no request log beyond byte counts, and no check that the name inside the tunnel is the name that was asked for. An allowed host that accepts uploads is a way out for data. - **Nothing reads content.** Request and response bodies are not inspected in either direction. A tool's result is checked against its declared shape, not searched for instructions. Prompt injection hidden in well-formed text reaches the model. - **A process running as you on your computer is out of scope.** The service's socket is yours. A program running under your account can call it as a person, read `~/.kosa8`, and approve a held call. The terminal check on `kosa8 mcp approve` stops an agent approving its own call through a pipe, and nothing more determined. Keep agents in sandboxes; that is what the sandbox is for. - **First-use pinning trusts the first definition.** If a tool server is hostile the first time it is started, its definitions are pinned as they are. Pins listed in your organisation's signed policy remove that gap; local pinning alone does not. - **Not every agent can be brokered.** Brokering needs a command-line tool that accepts a base URL. The Copilot CLI does not, so that kit passes `GITHUB_TOKEN` into the sandbox, and `kosa8 agent run` says so every time. - **A sandbox you create by hand is open by default.** `kosa8 sandbox create` with no network flag has an unrestricted network unless your organisation sets `default_egress`. Only sandboxes an agent creates, and brokered kits, are closed by default. ## Where to go next [`Keep API keys out of the sandbox` How the broker lets an agent use a key it never holds.](https://docs.kosa8.com/guides/credentials/) [`Network rules` What a sandbox can reach, and where that is enforced.](https://docs.kosa8.com/guides/network/) [`Tool rules` Decide a tool call by its arguments, and hold some for a person.](https://docs.kosa8.com/guides/tool-rules/) [`The audit log` What is written down, and how to prove nobody edited it.](https://docs.kosa8.com/guides/audit/) --- Source: https://docs.kosa8.com/guides/secure-agents/ # Keep API keys out of the sandbox An AI agent needs an API key to do anything. It does not need to hold one. With a brokered kit the key stays on your computer; the sandbox is given a placeholder that only works from inside that sandbox, and the real key is added to each request after it has left. ## What changes A *kit* is the recipe `kosa8 agent run` builds an agent's sandbox from: an image, a command, the hosts it may reach, and its credentials. Kits used to copy your key into the sandbox. Brokered kits do not. #### Key passed in The real key is an environment variable inside the sandbox. - The agent can read it. So can every package it installs and every script it runs. - It can be sent to any host the sandbox is allowed to reach. - Once copied out, it works from anywhere until you revoke it. #### Key brokered The sandbox holds a random placeholder and the address of a broker. - Reading the environment yields nothing that works outside this sandbox. - The real key is only ever sent to the one upstream it belongs to. - When the sandbox stops, the placeholder stops meaning anything. The *egress broker* is the part that makes this work. It is a small HTTP proxy that runs inside the `kosa8d` service on your computer, one per sandbox. "Egress" means traffic leaving the sandbox. The broker is the only way out a brokered sandbox has. ## One request, start to finish > Diagram: The agent's command-line tool, inside the sandbox, sends a request carrying a placeholder instead of a key. It leaves through a single open port, the pinhole, and arrives at the egress broker on your computer. The broker checks the method, the path and the placeholder, replaces the placeholder with the real key held in memory, and forwards the request over verified TLS to the one upstream the key belongs to. The decision is written to the audit log without the key. The key and the agent are never in the same place. The swap happens after the request has left the sandbox. 1. The agent's tool reads its key and its API address from the environment, as it always does. Both are stand-ins. 2. It sends the request to that address, with the placeholder where the key would go. One port is open for this; nothing else is. 3. The broker checks the method and path against the route, and requires the right placeholder. 4. It writes the real key into the header, in place of the placeholder. 5. The request goes to the one upstream that key belongs to. Redirects are not followed, so the upstream cannot bounce the key elsewhere. The answer streams back unbuffered. 6. Allowed or refused, the request is recorded: host, port, method, path and status. ### What a stolen placeholder is worth Assume the worst: something in the sandbox reads the placeholder and sends it to an attacker. This is what they can do with it. > Diagram: A placeholder copied out of a sandbox is tried in three places. Sent straight to the API, it is not a key and means nothing. Sent to another sandbox's broker, it does not match that sandbox's own random placeholder and is refused with 403. After the sandbox it came from stops, the broker and the key it held are gone. Away from its own sandbox and its own broker, a placeholder is a random string. The one place it does work is where it was issued: inside that sandbox, through that broker, while it runs. That is still a real power, and it is the first item under [limits]. ## Try it ### Check what a kit will do before it runs Have your key in your shell's environment the way the tool normally expects it, for example `ANTHROPIC_API_KEY`. Then ask for a dry run, which prints the plan and starts nothing. ``` $ kosa8 agent run --dry-run claude kosa8: kit claude (node:22-slim) 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: --dry-run, stopping before creating the sandbox ``` ### Run it Flags go before the kit name. `-w` names the one folder the agent may see; it defaults to the current directory. ``` $ kosa8 agent run -w ~/code/api claude kosa8: kit claude (node:22-slim) 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 ``` The fourth line is the one to look for. If instead you see a line that begins `kosa8: passing`, that kit is putting a real key in the sandbox. ### If the key is not set `kosa8 agent run` reads the key from its own environment. If the variable is missing it says so, and that credential's route is left out, so the agent fails to authenticate instead of reaching the API with nothing: ``` kosa8: warning: ANTHROPIC_API_KEY is not set on the host; the agent may fail to authenticate ``` The sandbox is removed when the agent exits, and the broker with it. `--keep` keeps the sandbox; see [where the key lives] for why a kept sandbox does not keep its broker across a restart. All the flags are on the [agent run](https://docs.kosa8.com/reference/agent-run/) reference page. ## Which kits are brokered `kosa8 agent kits` lists the kits you can run. The `KEYS` column says where each one's credentials will be. ``` $ 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-slim 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 ``` | KEYS | Meaning | |---|---| | brokered | Every credential the kit names stays on your computer. The sandbox holds placeholders. | | passed in | The real value is copied into the sandbox's environment, where the agent can read it. | | brokered + passed in | Some of each. The run's opening lines say which is which. | | none | The kit uses no credentials. | The API's host does not appear in the `NETWORK` column of a brokered kit. That is deliberate: `claude` lists the package registry and a telemetry host, and reaches `api.anthropic.com` only through its credential's route. The agent cannot open its own connection to the API, with or without a key. ### What each brokered kit does | Kit | Key read on your computer | Sent only to | In the header | The sandbox is given | |---|---|---|---|---| | `claude` | `ANTHROPIC_API_KEY` | `api.anthropic.com` | `x-api-key` | `ANTHROPIC_BASE_URL`, `ANTHROPIC_API_KEY` (placeholder) | | `codex` | `OPENAI_API_KEY` | `api.openai.com` | `Authorization: Bearer` | `OPENAI_BASE_URL`, `OPENAI_API_KEY` (placeholder) | | `gemini` | `GEMINI_API_KEY` | `generativelanguage.googleapis.com` | `x-goog-api-key` | `GOOGLE_GEMINI_BASE_URL`, `GEMINI_API_KEY` (placeholder) | | `opencode` | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` | each to its own API | as above | both base URLs and both placeholders | ### The three exceptions **copilot still passes its token in.** Brokering works by pointing the agent's tool at the broker instead of at the API, so it needs a tool that accepts a different base URL. The Copilot CLI has no such setting. Its kit therefore still copies `GITHUB_TOKEN` into the sandbox, and every run says so: ``` kosa8: passing GITHUB_TOKEN into the sandbox as plain environment variables — the agent can read them ``` Give that kit a token with the narrowest scope that works, and treat it as exposed to whatever the agent runs. **cursor and azmx are unavailable.** A kit installs the agent's tool before it hands over a key, so kosa8 will not ship a kit whose installer it cannot verify. The `cursor` kit used to install an npm package named `cursor-agent`, which is not published by Cursor. The `azmx` kit names a package that is not registered at all, so anyone could publish under that name and be handed the keys. Both refuse to run and say why: ``` $ kosa8 agent run cursor 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`) ``` See [Kits](https://docs.kosa8.com/guides/kits/) for pinned packages and signing. ## Broker your own tool's key Any HTTP API whose client lets you set its base URL can be brokered. You describe the credential in a kit file under `credentials:`. This is a complete kit: a shell that can call a weather API without holding its key. ``` # weather.yaml name: weather description: A shell that can call a weather API without holding its key image: alpine:latest command: [sh] egress_allow: - docs.example.com credentials: - name: weather upstream: https://api.example.com secret: WEATHER_API_KEY header: Authorization scheme: "Bearer " methods: [GET] paths: [/v1/forecast] env: WEATHER_API_URL: "{{url}}" WEATHER_API_KEY: "{{placeholder}}" ``` ``` $ kosa8 agent run --dry-run ./weather.yaml kosa8: warning: kit file ./weather.yaml is unsigned: nothing vouches for its contents (sign it with `kosa8 agent sign`, and pin the key with `kosa8 policy trust`) kosa8: kit weather (alpine:latest) kosa8: workspace /Users/you/code/api -> /work (nothing else on the host is visible) kosa8: network closed except through the egress broker: docs.example.com kosa8: brokering WEATHER_API_KEY -> api.example.com (the sandbox holds a placeholder, never the key) kosa8: --dry-run, stopping before creating the sandbox ``` Inside that sandbox, a client that reads `WEATHER_API_URL` and `WEATHER_API_KEY` can `GET` anything under `/v1/forecast` at `api.example.com`, and can open `docs.example.com`. It can do nothing else on the network. ### Every field | Field | Required | What it is | |---|---|---| | `name` | yes | The route's name: 1 to 32 characters of `a-z`, `0-9` and `-`, unique within the kit. It becomes the first path segment of the address the sandbox calls. | | `upstream` | yes | The only place the key is ever sent. It must be `https://host` with no path, no query and no `user@` part. Put a path under `paths` or after `{{url}}` instead. | | `secret` | yes | The *name* of the environment variable on your computer that holds the real key. Never the key itself. `kosa8 agent run` reads it when it starts. | | `header` | yes | The request header the upstream reads its key from, such as `x-api-key` or `Authorization`. Headers that proxies remove (`Connection`, anything starting `Proxy-`) and `Host` or `Content-Length` are refused. | | `scheme` | no | Text written before the key in the header. For a bearer token it is `"Bearer "`, with the space. The sandbox must send the same scheme before its placeholder. | | `methods` | no | The HTTP methods the key may be used with. Left out, any method is allowed except `TRACE`, which is never forwarded. `CONNECT` and `TRACE` cannot be listed. | | `paths` | no | Prefixes of the upstream path the key may be used on. Left out, any path is allowed. Each must start with `/` and have no empty, `.` or `..` segment. | | `env` | yes | The environment variables the sandbox is given in place of the real ones. In a value, `{{url}}` becomes the route's address at the broker and `{{placeholder}}` becomes the stand-in for the key. At least one variable must contain `{{placeholder}}`. | Three details decide whether a route means what you intended. - **Paths match on segment boundaries.** `/v1/messages` covers `/v1/messages` and `/v1/messages/batches`. It does not cover `/v1/messages_admin`, which a plain text prefix would have let through. - **Methods are upper-case.** Write `GET`, `POST`. An entry in another case is folded to upper-case when the route is loaded, and a request's method is compared exactly. - **`{{url}}` can carry a suffix.** The built-in `codex` kit sets `OPENAI_BASE_URL` to `"{{url}}/v1"` because that tool expects the version in its base URL. Quote any value that contains braces, or YAML reads it as a mapping. ### When the kit file is wrong A kit is checked when it is loaded, and the message names the credential and the fix. These are the messages for three common mistakes: ``` kosa8: ./weather.yaml: credential "weather": env must give the sandbox {{placeholder}} in some variable kosa8: ./weather.yaml: credential "weather": upstream "https://api.example.com/v1" must be https://host with no path kosa8: ./weather.yaml: credential name "Weather_API" must be 1-32 characters of a-z, 0-9 and '-' ``` Path prefixes and header names are checked a step later, by the broker, when the sandbox is created. **On a governed machine** Where your organisation has pinned a signing key, a kit file must be signed by it or it does not load: `kosa8: kit ./weather.yaml is not signed, and this machine only runs kits signed by its org's key; have it signed with `kosa8 agent sign. See [agent sign](https://docs.kosa8.com/reference/agent-sign/). ## What the sandbox sees This is the environment of a real brokered sandbox, from a test kit with one credential named `echo` for `httpbin.org`: ``` ECHO_KEY=kosa8-placeholder-1803c857986afe6bc9f59600e4d6f6f8 ECHO_URL=http://192.168.127.254:57504/echo HTTPS_PROXY=http://192.168.127.254:57504 ``` | Variable | What it holds | |---|---| | the `{{placeholder}}` variable | `kosa8-placeholder-` and 32 random hexadecimal characters, fresh for each sandbox. Recognisable on purpose: one found in a log is obviously not a real key. | | the `{{url}}` variable | The broker as the sandbox reaches it, then the route's name. `192.168.127.254` is an address on the sandbox's own private network that leads to the broker; the port is chosen when the sandbox is created. | | `HTTPS_PROXY`, `HTTP_PROXY` | The broker again, so that everything else the sandbox fetches goes through it too. Both are also set in lower-case, because tools disagree about which spelling they read. | | `NO_PROXY` | The broker's own address, so a request to a route is not itself sent through the proxy. | The plain address is `http://`, not `https://`. That hop never leaves your computer: it runs from the virtual machine to a port on the loopback interface of the same machine. The broker makes the connection to the upstream over TLS, checked against the upstream's own name. A request to `$ECHO_URL/headers` with the placeholder asked the upstream to report what it received: ``` "Authorization": "Bearer real-secret-xyz-0123456789" ``` The upstream saw the real key. The sandbox that sent the request never had it. ## What the broker refuses A refusal is an ordinary HTTP response, so the agent's tool fails immediately with a status it understands. The body is one line that starts `kosa8:` and says what to change, for example `kosa8: this route needs the sandbox's placeholder credential`. The same decision goes to the audit log with a short, stable reason. | Request | Status | Reason in the audit log | |---|---|---| | Placeholder missing or wrong | `403 Forbidden` | `placeholder credential missing or wrong` | | Path outside the route's prefixes | `403 Forbidden` | `path not allowed on route` | | Method not on the route's list | `405 Method Not Allowed` | `method not allowed on route` | | Placeholder also in the URL | `400 Bad Request` | `placeholder also sent in the URL` | | A `.` or `..` in the path | `400 Bad Request` | `path has a . or .. segment` | | An encoded dot or slash, or `\` | `400 Bad Request` | `path has an encoded dot or slash, or a backslash` | | A route name that does not exist | `404 Not Found` | `no such route` | | `TRACE`, on anything | `405 Method Not Allowed` | `TRACE is never forwarded` | | A host that is not listed | `403 Forbidden` | `host not in allow-list` | For a route the checks run in this order: the path's spelling, the method, the path's prefix, then the placeholder. Paths with two possible readings are refused instead of tidied up, because the broker cannot know which reading the upstream would take. `TRACE` is refused because its purpose is to echo the request back, key included. Each of the first five rows was produced by a real request. These are the entries they left: ``` sandbox.egress.allow {"kind":"route","host":"httpbin.org","port":443,"method":"GET","path":"/headers","route":"echo","status":200,"bytes_in":225} sandbox.egress.deny {"kind":"route","host":"httpbin.org","port":443,"method":"GET","path":"/headers","route":"echo","reason":"placeholder credential missing or wrong"} sandbox.egress.deny {"kind":"route","host":"httpbin.org","port":443,"method":"GET","path":"/anything","route":"echo","reason":"path not allowed on route"} sandbox.egress.deny {"kind":"route","host":"httpbin.org","port":443,"method":"POST","path":"/get","route":"echo","reason":"method not allowed on route"} sandbox.egress.deny {"kind":"route",...,"reason":"placeholder also sent in the URL"} sandbox.egress.deny {"kind":"route",...,"path":"/get/../anything","reason":"path has a . or .. segment"} ``` An entry carries the path and never the query string, which is where keys and personal data tend to travel. Entries are limited to 120 a minute for each sandbox and each kind of request (refused, credentialed, other allowed), so ordinary traffic cannot crowd out a credentialed call or a refusal; past that, requests are counted and the count rides on the next entry. See [The audit log](https://docs.kosa8.com/guides/audit/). ## Where the real key lives, and for how long | Place | Is the real key there? | |---|---| | Your shell, and `kosa8 agent run` | yes It is read from the environment variable the kit names, and sent to the service over a socket only you can open. | | The `kosa8d` service's memory | yes For as long as the sandbox runs. | | The sandbox | no It holds the placeholder. | | The sandbox's record on disk | no The record names the routes and hosts, never a credential or a placeholder. | | A snapshot | no | | The audit log | no | | Responses from the broker | no Not in a response it writes, not in an error. A response *header* from the upstream that echoes the key is dropped. | When the sandbox stops, its broker stops and the key is dropped from memory. Nothing wrote it down, so nothing can bring it back. **A brokered sandbox does not come back brokered** Stop a brokered sandbox and start it again, restore it from a snapshot, fork it, or restart the service, and it comes up with its network still closed and *no broker*. A sandbox made by `kosa8 agent run` can then reach nothing. That is the safe direction: carrying the key across would mean storing it. `kosa8 agent run` makes a fresh sandbox every time for this reason. To continue work, run the kit again on the same folder. ## Limits #### What this does not do - **The placeholder is a capability.** The agent cannot learn your key, and it can spend it: any request the route's `methods` and `paths` admit is made with the real credential, for as long as the sandbox runs. The broker cannot tell a good request from a bad one. Narrow the route, and scope the key itself at the provider. - **Bodies are not inspected.** An upstream that echoes the key back in a response *body* returns it to the agent. Only headers are checked. Request bodies to an allowed destination can carry anything the agent can read. - **The check for a misplaced placeholder is literal.** The broker looks for the placeholder in other headers and in the URL as plain text. An agent that encodes it first is not caught, and loses nothing but a placeholder. - **It needs a tool that accepts a base URL.** One that does not, such as the Copilot CLI, cannot be brokered and has its key passed in. - **The placeholder is not authentication against someone who can read the sandbox.** It keeps other programs on your computer from using the broker's port as a free key. Anything inside the sandbox has it. - **Other hosts go through an opaque tunnel.** Method and path limits apply to routes and to plain HTTP. HTTPS to a host in `egress_allow` is a tunnel the broker does not look inside. See [Network rules](https://docs.kosa8.com/guides/network/). - **No WebSocket on a route.** HTTP Upgrade is not forwarded on routes or plain HTTP. - **The key is in memory on your computer.** A program running as you, or as an administrator, is outside what this protects against. ## Where to go next [`Network rules` What else a sandbox can reach, and the pinhole the broker sits behind.](https://docs.kosa8.com/guides/network/) [`Kits` Write, pin and sign the recipe an agent's sandbox is built from.](https://docs.kosa8.com/guides/kits/) [`Securing an agent` Where the broker sits among the other layers.](https://docs.kosa8.com/guides/secure-agents/) [`The audit log` Read the record of every brokered request.](https://docs.kosa8.com/guides/audit/) --- Source: https://docs.kosa8.com/guides/credentials/ # Network rules A sandbox has its own private network, and you decide what it can reach beyond it: everything, nothing, or a list. The decision is enforced on your computer, in a place nothing inside the sandbox can touch. This page covers the rules, where they are checked, what a refusal looks like, and the stricter layer the egress broker adds on top. ## The three policies Every sandbox has one network policy, fixed when it is created. kosa8 calls it the *egress* policy: egress is traffic leaving the sandbox. **any**Everything is forwarded. Nothing is refused and nothing is logged. This is what you get from `kosa8 sandbox create` if you say nothing; see [defaults]. **deny**Nothing leaves your computer: no connection, no datagram, and no DNS lookup, because a lookup is itself a message to the outside world. **allow: a list**Only the hosts, addresses and networks you list. Everything else is refused. ``` $ kosa8 sandbox create dev --egress-allow registry.npmjs.org sandbox dev ready in 737ms (32 GB disk) $ kosa8 sandbox egress dev policy: allow:registry.npmjs.org (enforced on the host) frames: 3 passed, 0 denied, 1 dropped as link noise dns: 2 answered, 0 refused ``` ### What can go in a list | Entry | Example | What it admits | |---|---|---| | An exact name | `registry.npmjs.org` | That name and no other. Allowing `example.com` does not allow `www.example.com`. | | A wildcard | `*.example.com` | Every name below `example.com`, at any depth: `a.example.com` and `a.b.example.com`. Not `example.com` itself; list that too if you need it. | | An IPv4 address | `203.0.113.10` | That address. | | An IPv4 network | `10.20.0.0/16` | Every address in it, written as a CIDR range. | Names are compared in lower-case with any trailing dot removed. A wildcard matches on the dot, so `*.example.com` does not admit `evil-example.com` or `example.com.evil.net`. ### What is refused when you write the rule A typo in a security setting is better reported than guessed at, so kosa8 refuses any policy it does not fully understand instead of treating it as something close. | You wrote | kosa8 says | |---|---| | `*` | `a bare * would allow every name; use --egress any, or list *.domain entries` | | `*.com` | `com is a public suffix, under which anyone can register a name, so the wildcard would admit strangers' hosts; allow a domain below it, as in *.example.com` | | `api.*.com`, `*api.com` | `a wildcard must be the whole first label, as in *.example.com; a * anywhere else is not supported` | | `*.10.0.0.1` | `a wildcard over an address matches nothing; list the address or a CIDR` | | An IPv6 address or network | `only IPv4 is routed from a sandbox; list an IPv4 address or a hostname` | | An empty list | `allows nothing; use deny, or list the hosts to allow` | | Anything else | `want any, deny, fakenet, or allow:HOST[,HOST...]` | Every single-label wildcard (`*.com`, `*.io`) is refused that way, along with a built-in list of country second levels and hosting platforms where strangers can publish, such as `co.uk`, `github.io`, `vercel.app` and `amazonaws.com`, so `*.co.uk` and `*.github.io` get the same answer. The list is a guard against the obvious mistake. It is not the full Public Suffix List, so a wildcard over a suffix it does not know is accepted. ### Where you set it | Where | How | |---|---| | `kosa8 sandbox create` | `--egress any` or `--egress deny`; `--egress-allow HOST`, repeatable, for a list. A list implies deny for everything else. `--egress any` together with `--egress-allow` is refused as a contradiction. | | `kosa8 mcp add` | The same two flags, for the sandbox a third-party tool server runs in: `--egress any` or `deny`, and `--egress-allow` for hosts or `*.domain` wildcards. | | A kit file | `egress: deny`, or `egress_allow:` with a list. `kosa8 agent run --no-network` overrides the kit with deny. | | An AI agent, over MCP | The `egress` and `egress_allow` arguments of the `sandbox_create` tool. | | Your organisation | `default_egress` in the signed policy, applied to any sandbox created without a rule of its own. | `kosa8 sandbox create` accepts a fourth value, `--egress fakenet`, for malware analysis: nothing leaves, and a simulated internet on your computer answers and records everything. It is not covered here; see `kosa8 lab --help`. The full flag list is on the [sandbox create](https://docs.kosa8.com/reference/sandbox-create/) and [mcp add](https://docs.kosa8.com/reference/mcp-add/) reference pages. ## Where it is enforced The policy is applied twice, in two different places, and only one of them is the boundary. > Diagram: A container in the sandbox sends a packet. It first meets a firewall inside the virtual machine, built from the same policy, which root in the sandbox can flush. It then leaves the virtual machine and meets the network guard on your computer, which reads every frame the machine sends before anything forwards it and which nothing in the machine can reach. The guard passes allowed traffic unchanged and refuses the rest with a reset or an ICMP message, and logs it. The inner firewall is a convenience. The guard on your side is the boundary. 1. A program in the sandbox opens a connection. 2. A firewall inside the virtual machine applies the policy to container traffic first. It is inside, so whoever is root inside can remove it. 3. The packet then leaves the machine, and the only way out is through the guard, a part of the `kosa8d` service on your computer. 4. Traffic to a destination the policy admits is passed on unchanged. 5. Anything else is dropped, answered so the program fails at once, counted, and written to the audit log. The guard is the boundary because of where it sits. Every frame the virtual machine sends is delivered to a socket that the service reads, and it is read there before anything decides to forward it. Root inside the sandbox can rewrite the machine's own firewall, routes and DNS settings. It cannot reach a process on your computer. The inner firewall stays for two reasons: it refuses container traffic before it leaves the machine at all, and it fails closed if the outer layer were ever misconfigured. One exception: a policy with a `*.domain` entry is enforced by the guard alone, because the inner layer cannot resolve a wildcard ahead of time and steps aside. The guard looks at frames, not connections, so its check covers things a connection-level filter never sees. IPv6, multicast and other link-level chatter are dropped and counted as "link noise", not logged one by one: a Linux machine sends some at every boot. ## How names are resolved A list of names is only sound if the sandbox cannot decide what those names mean. So the sandbox does not get to ask a DNS server of its choosing. > Diagram: The sandbox sends a DNS question to any server. The guard intercepts it and never forwards it. If the name is on the list, your computer's resolver looks it up, the answer is returned, and exactly those addresses become reachable. Any other name is answered REFUSED and logged. The addresses a sandbox may reach are exactly the ones your computer looked up for it. 1. A program asks for a name. It can address the question to any server; it makes no difference. 2. Every DNS query is taken by the guard and answered there. None is forwarded. 3. For a listed name, or one under a wildcard, your computer's own resolver does the lookup. 4. The answer goes back, and those addresses, and only those, become reachable. 5. A name that is not on the list gets `REFUSED`, and the attempt is recorded with the name. Two consequences are worth knowing. A sandbox cannot "resolve" an allowed name through a server it controls and have that address admitted, because the only answers that count are the ones your computer obtained. And no bytes the sandbox chose reach a resolver outside your computer, except as the name being looked up. - Under `deny`, every lookup is refused. - Answers are IPv4 only, up to 16 addresses, with a lifetime of 60 seconds so the sandbox asks again often. An address it was told stays reachable for 24 hours. - For an allowed name, a question kosa8 does not forward (an IPv6 address, a mail record) gets an empty answer, so the program moves on at once. - Entries that are addresses or networks need no lookup; they are reachable from the start. ### Names cannot point inward An allowed name is a statement about the public internet. Whoever controls that name's DNS decides what it resolves to, and could point it at `127.0.0.1`, at the cloud metadata address `169.254.169.254`, or at a machine on your own network. That would turn "may reach api.example.com" into a route to your computer. This trick is called DNS rebinding. So an answer is only handed to the sandbox, and only admitted, if it is a public address. Private, loopback, link-local, carrier-grade NAT and reserved addresses are withheld, and the attempt is logged as `allowed name resolved to a private, loopback or link-local address`. If you do mean a private host, list its address or network as a literal entry. That is you saying so on purpose, and it is honoured. ## What a refusal looks like A refused connection fails immediately. The guard answers on behalf of the destination, so a program gets an error instead of waiting out a timeout. | The sandbox sends | It gets back | A program sees | |---|---|---| | A new TCP connection | A TCP reset (RST) | `Connection refused` | | A UDP datagram | ICMP "administratively prohibited" | An unreachable error | | A DNS question | A `REFUSED` answer | `server can't find example.com: REFUSED` | | IPv6, multicast, other link traffic | Nothing | No answer. Dropped and counted as link noise, not logged | From your side, `kosa8 sandbox egress` shows the policy in force and the counts since the sandbox booted: frames passed, denied and dropped, and lookups answered and refused. Its output is at the top of this page. For a policy with wildcards it adds a `wildcard:` line for each entry, listing the names the sandbox was answered for under it: the first 64, then a count. That is how you find out what a wildcard is really being used for. For an open sandbox it says so: `nothing is refused; create with --egress deny or --egress-allow to restrict`. Each refused attempt is also an entry in the audit log, with the action `sandbox.egress.deny`. These two are from a real sandbox whose policy was `deny`: a lookup, and a connection to a port on the host. ``` sandbox.egress.deny {"proto":"dns","dst":"8.8.8.8","port":53,"name":"example.com","reason":"name not in egress policy deny"} sandbox.egress.deny {"proto":"tcp","dst":"192.168.127.254","port":22,"reason":"not in egress policy deny"} ``` A sandbox can be refused far faster than a tamper-evident log should grow, so at most 20 refusals a minute are written for each sandbox. The rest are counted, and the count is carried on the next entry as `suppressed`. The totals in `kosa8 sandbox egress` are exact. See [The audit log](https://docs.kosa8.com/guides/audit/). ## The egress broker, on top Everything so far is a rule about *addresses*. That has a hard ceiling. Once a name has been resolved the guard sees only an address, and one address can serve thousands of sites. It cannot see the name inside an encrypted connection, a port's purpose, a method or a path. The *egress broker* is a second layer for sandboxes that need more than that. It is an HTTP proxy inside the `kosa8d` service, one per sandbox. A brokered sandbox's address policy is `deny`, and the broker is its only way out. Because programs have to ask the broker for each destination, every request names where it is going. - **By name, not address.** A request names its host, either as `CONNECT host:443` or as a plain `http://host/…` URL. The same name rules apply: exact names, and `*.domain` wildcards. - **Ports.** A listed host is reachable on 443 and 80. Any other port is refused as `port not allowed for host`. - **Methods, for plain HTTP.** On an unencrypted request the broker sees the method and the path, records both, and can hold a host to a list of methods. - **CONNECT tunnels.** HTTPS goes through a tunnel. The broker checks the name and port, then carries encrypted bytes it does not open. - **The address is vetted once.** The broker resolves the name on your computer, refuses private and internal addresses as the guard does, and connects to the address it checked, so a second lookup cannot redirect it. - **Credentials.** For a named upstream the broker can add an API key the sandbox never holds. That is its own page: [Keep API keys out of the sandbox](https://docs.kosa8.com/guides/credentials/). A kit with `credentials:` gets a broker automatically, and the hosts in its `egress_allow` become the broker's list, each on ports 443 and 80. The sandbox is given `HTTPS_PROXY` and `HTTP_PROXY` pointing at the broker. These results are from inside one such sandbox, which allowed `example.com`: | Attempt | Result | |---|---| | `http://example.com/` through the proxy | allowed The page is returned. | | `http://neverssl.com/`, not listed | refused `HTTP/1.1 403 Forbidden` | | `CONNECT evil.example:443` | refused `HTTP/1.1 403 Forbidden` | | `CONNECT 169.254.169.254:80` | refused `403` | | Straight to `1.1.1.1`, around the proxy | refused `Connection refused`, by the guard | | `nslookup example.com` | refused `REFUSED`: even an allowed host is resolved by the broker, not the sandbox | Unlike the guard, the broker records what it *allowed* as well as what it refused: ``` sandbox.egress.allow {"kind":"http","host":"example.com","port":80,"method":"GET","path":"/","status":200,"bytes_in":577} sandbox.egress.deny {"kind":"http","host":"neverssl.com","port":80,"method":"GET","path":"/","reason":"host not in allow-list"} sandbox.egress.deny {"kind":"connect","host":"evil.example","port":443,"reason":"host not in allow-list"} ``` The refusal the program receives is one line that says what to change, for example `kosa8: neverssl.com is not in this sandbox's egress allow-list; add it to the sandbox's egress policy (an exact name does not cover its subdomains)`. **Lists for a brokered kit** The broker admits single hosts, so a brokered kit's `egress_allow` takes names, `*.domain` wildcards and IPv4 addresses, but not CIDR networks. A program that ignores the proxy settings reaches nothing at all. The broker's own rule format can carry a port list and a method list for each host. A kit file has no field for either today: every host in a kit's `egress_allow` gets ports 443 and 80 and any method. ### The pinhole The broker listens on your computer's loopback interface, `127.0.0.1`, on a port chosen when the sandbox is created. The sandbox reaches it at `192.168.127.254`, an address on its own private network that kosa8 maps to your loopback. That mapping needs care. It is by address, which means every port, which means every service listening on your computer's loopback: a database, a development server, an SSH daemon. So the mapping is never set up without the guard's check in front of it, and the check is narrow. > Diagram: The agent in the sandbox dials 192.168.127.254, which maps to the loopback interface of your computer. The guard passes only TCP to the broker's own port. A connection to port 22, or to any other port on your loopback, is refused. One address, one protocol, one port. The port number differs for every sandbox. 1. The sandbox is told the broker's address and port in its environment. 2. The guard checks each frame sent to that address: it must be TCP, not a fragment, and to the broker's port. 3. A connection to the broker passes. 4. A connection to another port, here SSH on 22, is refused before anything on your computer is dialled. 5. So is everything else listening on your loopback. Three things keep the hole one port wide. The check runs before the policy's own list, so a list that happens to cover `192.168.127.254` with a wide network does not widen it. A fragment has no port to check, so fragments to that address are refused. And kosa8 refuses to start a broker beside a policy that has no guard at all (`any`): a broker next to an open network would be optional. ## Address rules or the broker | | Address rules (the guard) | The egress broker | |---|---|---| | Hostname | Only at lookup time. After that it sees addresses, and cannot see the name inside TLS. | yes Every request names its host. | | Port | no An allowed address is reachable on every port. | yes 443 and 80 for a host a kit lists. | | Method | no | partly On plain HTTP and credential routes. Not inside a CONNECT tunnel. | | Path | no | partly Limited on credential routes; recorded for plain HTTP. Not inside a tunnel. | | Credential | no The sandbox must hold its own key. | yes Added on your computer; the sandbox holds a placeholder. | | Allowed traffic in the audit log | no Refusals only. Allowed frames are counted. | yes Each allowed request or tunnel is an entry. | | Protocols | Anything over IPv4: TCP, UDP, ICMP. | HTTP, and TCP through a CONNECT tunnel. | | Networks (CIDR) | yes | no Single hosts. | | Programs must cooperate | no It is transparent. | They must use the proxy settings. Those that do not reach nothing. | Use address rules for a build sandbox that needs a registry and nothing sensitive. Use a brokered kit when the sandbox runs an agent with a key. ## Defaults What a sandbox can reach when nobody said anything depends on who created it. | Created by | Default | Why | |---|---|---| | You, with `kosa8 sandbox create` | any | Unless you pass `--egress` or `--egress-allow`, or your organisation's signed policy sets `default_egress`, which is then applied instead. | | An AI agent, with the MCP `sandbox_create` tool | deny | The agent gets a network only by asking in the call's arguments: `egress_allow` with the hosts it needs, or `egress: any`. Both are in the audit entry. Where the organisation sets `default_egress`, that is applied instead. | | A built-in agent kit | brokered | `claude`, `codex`, `gemini` and `opencode` run with the network closed except through the broker. `copilot` has an address allow-list. `shell` has no network. | | Your own kit | whatever it says | A kit that sets neither `egress` nor `egress_allow` and has no credentials is open, and the run says so: `kosa8: network UNRESTRICTED — this kit sets no egress_allow`. | An agent-created sandbox with no network can still pull images: the service fetches those on your computer, not from inside the sandbox. If an agent sets both arguments in a way that conflicts, the call is refused: `egress_allow already limits the network to the hosts it lists; do not also set egress to "any"`. Every `kosa8 agent run` prints its network in the first lines, one of: ``` kosa8: network closed except through the egress broker: registry.npmjs.org, statsig.anthropic.com kosa8: network limited to api.githubcopilot.com, api.github.com, registry.npmjs.org kosa8: network denied ``` ## Packet capture When you need to see exactly what a sandbox said, capture it. Every frame the sandbox sends or receives is written, on your computer, to a pcapng file that opens in Wireshark or tshark. The sandbox cannot see, stop or edit the capture. ``` # from the first frame, boot included $ kosa8 sandbox create dev --egress-allow registry.npmjs.org --pcap ./dev.pcapng # or on a sandbox that is already running $ kosa8 sandbox pcap dev ./dev.pcapng $ kosa8 sandbox pcap dev --stop $ tshark -r dev.pcapng -Y dns ``` `--pcap FILE` on `sandbox create` captures from the first frame, boot included. The file rotates: at `--pcap-max-mb` (default 64) a segment closes and `FILE.2.pcapng` follows, and `--pcap-files` (default 8) is how many are kept. The SHA-256 of each closed segment goes into the audit log as `sandbox.pcap.segment`, so a capture edited afterwards no longer matches its record. **A capture holds secrets** It contains everything the sandbox said in the clear: tokens, cookies, keys sent without TLS. The files are readable only by you. Treat them like the secrets they may contain. See [sandbox pcap](https://docs.kosa8.com/reference/sandbox-pcap/). ## Limits #### What this does not do - **IPv4 only.** A sandbox has no IPv6 route at all. IPv6 frames are dropped, and an IPv6 entry in a list is refused, not ignored. - **Under address rules, an allowed address is allowed on every port and protocol.** Allowing `registry.npmjs.org` admits the addresses it resolves to for TCP on any port, UDP and ICMP. If those addresses belong to a content delivery network, every other site served from them is reachable too. The guard cannot tell them apart. - **A wildcard lets data out through DNS.** With `*.example.com` allowed, the sandbox may look up any name under it, and each lookup is carried by your computer's resolver to that domain's name servers. Whoever runs them can read data encoded in the names. `kosa8 sandbox egress` lists the names asked for; only allow a wildcard over a domain you trust. - **A CONNECT tunnel is opaque.** The broker knows the name and port, then carries TLS it does not open. Inside a tunnel there is no method or path filtering, no log beyond byte counts, and no check that the name inside the TLS connection is the name that was asked for. A method list on a host does not apply to HTTPS to that host. - **An allowed host that accepts uploads is a way out.** Neither layer reads what is sent. Allow few hosts. - **The policy is fixed at creation.** To change what a sandbox can reach, create another one. - **The public-suffix list is short.** It catches common mistakes, not every domain under which strangers can register names. - **The log is rate-limited.** Past 20 refusals a minute for the guard, and 120 entries a minute for each kind of broker request (refused, credentialed, other allowed), attempts are counted, not listed. - **It trusts your computer's resolver.** A name means whatever your computer's DNS says it means, apart from the private addresses that are withheld. ## Where to go next [`Keep API keys out of the sandbox` The broker's other job: a key the agent uses without holding.](https://docs.kosa8.com/guides/credentials/) [`Securing an agent` Where network rules sit among the other layers.](https://docs.kosa8.com/guides/secure-agents/) [`The audit log` Read and verify the record of what was refused and allowed.](https://docs.kosa8.com/guides/audit/) [`Policy reference` Set a default network rule for a whole organisation.](https://docs.kosa8.com/guides/policy-reference/) --- Source: https://docs.kosa8.com/guides/network/ # Tool servers (MCP) A tool server gives an AI assistant new abilities: read your tickets, open a pull request, query a database. kosa8 runs each one in a virtual machine of its own, puts one gateway between it and the assistant, and refuses to pass on a tool whose description has changed since you approved it. ## What MCP is The Model Context Protocol (MCP) is the common way an AI client asks another program what tools it offers and then calls them. A program that answers is an *MCP server*, or tool server: it publishes a list of tools, each with a name, a description and a schema for its arguments. ## Why a tool server is a risk There are two separate problems, and it helps to keep them apart. **It runs code**A tool server is a program somebody else wrote, usually installed with `npx` or `pip` a moment before it starts. Launched the usual way, it runs as you, with your files, your SSH keys and your network. **Its words are instructions**The model reads every tool's description before it decides what to do. A description is prose, and a model follows prose. A server that changes a description has changed what your assistant will do, without changing the tool's name or anything a permission list can see. kosa8 answers the first with a sandbox and the second with pins. The rest of this page is how. ## The picture > Diagram: On your computer, an AI client talks to kosa8 mcp serve, the gateway, over stdio or HTTP. The gateway talks to each third-party tool server, and each server runs in its own sandbox virtual machine with its own network rule. kosa8's own thirty tools are served by the same gateway without an extra machine. One door for the assistant, one machine per server. 1. Your AI client is configured with one MCP server: `kosa8 mcp serve`. 2. The gateway decides every call before it goes anywhere, and writes down what it decided. 3. Each server you registered runs as a container in a sandbox named `mcp-NAME`, with the network rule you gave it. 4. A server with no need for the network gets none. It cannot see the other servers, or your files. 5. kosa8's own tools come through the same gateway, under the same rules if your policy names them. ## Walkthrough ### Register a server Give it a name, the image to run in, what it may reach, and the command that starts it. Everything after `--` is the server's own command line. ``` $ kosa8 mcp add github --image node:22-alpine \ --egress-allow api.github.com --egress-allow registry.npmjs.org \ -e GITHUB_TOKEN=… \ --allow-tool list_issues --allow-tool create_issue \ -- npx -y @modelcontextprotocol/server-github registered github (image node:22-alpine) — served on next `kosa8 mcp serve` ``` A server started with `npx` has to download itself first, which is why the registry is on the list. A server already in its image needs only the hosts it works with. ### Check what is registered ``` $ kosa8 mcp ls NAME IMAGE COMMAND EGRESS TOOLS github node:22-alpine npx -y @modelcontextprotocol/server-github allow:api.github.com,registry.npmjs.org list_issues,create_issue ``` Nothing is running yet. Registration only writes the entry to `~/.kosa8/mcp-servers.json`. ### Point your AI client at the gateway Most clients launch an MCP server as a command and talk to it over standard input and output. Give them this command: ``` $ kosa8 mcp serve ``` When it starts, the gateway boots each registered server in its sandbox, asks it for its tools, and reports on standard error (never on standard output, which belongs to the protocol). This is a real run with a small test server named `echo` that has one tool: ``` kosa8 MCP gateway: AI session … kosa8 mcp gateway: echo: pinned 1 tool definitions on first use; a later change to any of them will be withheld until approved (`kosa8 mcp pins show echo`) kosa8 mcp gateway: echo up (1 tools) ``` A server that fails to start is reported and skipped. It never takes kosa8's own tools down with it. ### Or serve over HTTP ``` $ kosa8 mcp serve --http 127.0.0.1:7433 kosa8 MCP token (send as `Authorization: Bearer TOKEN`; shown once): … kosa8 MCP server listening on 127.0.0.1:7433 ``` Every request must carry that bearer token. Set `KOSA8_MCP_TOKEN` to choose the token yourself; otherwise a random one is printed once. Only loopback addresses are accepted unless you pass `--insecure-listen`. ### How the tools appear Each tool is listed to the assistant as `NAME_tool`: the server `github` with a tool `create_issue` becomes `github_create_issue`. Its description is prefixed with where it runs, `[github via kosa8 sandbox mcp-github]`, and it is marked as potentially destructive so that clients which ask before destructive calls will ask. A third-party tool can never replace one that already exists. If a name collides with one of kosa8's own tools or an earlier server's, the newcomer is skipped and the gateway says so. **The name kosa8 is reserved** Policy uses the server name `kosa8` for kosa8's own tools, so `kosa8 mcp add kosa8 …` is refused: `"kosa8" is how MCP policy names kosa8's own tools; register this server under another name`. ### Flags for `kosa8 mcp add` | Flag | What it does | Default | |---|---|---| | `--image` | The container image the server runs in. | `alpine` | | `--egress` | The sandbox's network rule: `any` or `deny`. | `any`, or the organisation's `default_egress` | | `--egress-allow` | Permit outbound traffic only to these hosts, or `*.domain` wildcards. Repeatable. Replaces `--egress`. | none | | `-e`, `--env` | `KEY=VALUE` for the server process. Repeatable. | none | | `--allow-tool` | Expose only this tool. Repeatable. An exact name, not a pattern. | all tools | **The default network is open** A server registered without `--egress` or `--egress-allow` can reach anything, unless your organisation sets `default_egress`. Name the hosts it needs. If you later tighten the rule in `mcp-servers.json`, the gateway notices on its next start and recreates the sandbox rather than reusing one made under the looser rule. ### Flags for `kosa8 mcp serve` | Flag | What it does | |---|---| | `--http ADDR` | Serve streamable HTTP on this address instead of stdio. Bearer-token authenticated. | | `--insecure-listen` | Allow `--http` on an address that is not loopback. Anyone who holds the token and can reach it can run containers on your machine. | | `--allow-mount DIR` | A host directory the assistant may share into sandboxes it creates: read-only, or writable when given as `DIR:rw`. Repeatable. With none, the assistant cannot share any host directory. | | `--session ID` | The AI session the audit entries belong to. A new one is made when omitted. | | `--sandbox NAME` | The sandbox the assistant itself works in, so its audit entries name it. | Two environment variables matter as well: `KOSA8_MCP_TOKEN` (the HTTP bearer token) and `KOSA8_AGENT_TOKEN`, which tells the gateway which agent it is serving. See [Agent identity](https://docs.kosa8.com/guides/agent-identity/). ## Pinned tool definitions The first time the gateway sees a server, it records a fingerprint of every tool that server offers. From then on a tool is passed to the assistant only while its fingerprint is unchanged. This is called *pinning*. ### What is in the fingerprint The fingerprint is a SHA-256 digest over six parts of the definition, put into one canonical form first so that the same definition sent with different key order or spacing is the same tool. | Part | Why it is included | |---|---| | Name | What the assistant calls. | | Title | What a client shows the person approving a call. | | Description | The words the model reads as instructions. | | Input schema | What the model is asked to fill in. | | Output schema | What the tool promises to return. | | Annotations | Hints such as "read-only" that a client trusts when deciding whether to ask. | ### When a definition changes The attack this stops is sometimes called a rug-pull: you approve a harmless tool, and later the server serves a tool with the same name and different words. > Diagram: On the left, the tool read_file as it was when first pinned, with a harmless description and digest A. On the right, the same tool name later, with a description that also tells the model to send a private key elsewhere, and digest B. The gateway compares the two digests before listing any tool, finds they differ, and withholds the tool from the model until a person approves the change. The name did not change. The digest did, and the digest is what was approved. 1. On first use the gateway pins each definition and tells you it has. 2. Later the server describes the same tool differently. 3. At the next start the gateway computes the digest again and compares. 4. The tool is left out of the list, the reason is printed and recorded, and the change waits for you. A tool that *appears* beside the approved ones is treated the same way: withheld until someone has looked. The gateway prints one line per withheld tool: ``` kosa8 mcp gateway: echo: tool "echo" withheld: its definition changed since it was pinned (sha256:…, now sha256:…) (review with `kosa8 mcp pins diff echo`, accept with `kosa8 mcp pins approve echo`) kosa8 mcp gateway: echo: tool "upload" withheld: it is new since this server's tools were pinned (review with `kosa8 mcp pins diff echo`, accept with `kosa8 mcp pins approve echo`) ``` ### Reviewing and accepting a change ``` $ kosa8 mcp pins show echo # what it is pinned to echo (image …) echo sha256:… 2 changed or new definitions withheld; see `kosa8 mcp pins diff echo` $ kosa8 mcp pins diff echo # what changed, in the words the model reads ~ echo CHANGED sha256:… -> sha256:… was: … now: … + upload NEW TOOL sha256:… description: … accept with `kosa8 mcp pins approve echo [TOOL...]` $ kosa8 mcp pins approve echo echo # accept one tool, or leave the names off for all pinned echo for echo; exposed on the next `kosa8 mcp serve` ``` If the description is the same and something else moved, the diff says so: `the description is unchanged; the schema, title or annotations differ`. | Command | What it does | |---|---| | `kosa8 mcp pins show [SERVER]` | Prints each pinned tool and its digest, in the form an organisation policy pins them. | | `kosa8 mcp pins diff SERVER` | Shows the withheld definitions: changed ones with the old and new description, new ones with theirs. | | `kosa8 mcp pins approve SERVER [TOOL…]` | Accepts the pending definitions, all or only those named. Takes effect at the next gateway start. | | `kosa8 mcp pins reset SERVER` | Forgets a server's pins so it is pinned afresh on next start. For a server you replaced on purpose. | `approve` and `reset` insist on a terminal. Run from a script or a pipe they answer kosa8 mcp pins approve` must be run by a person at a terminal`, so an assistant with a shell cannot quietly accept its own server's change. `kosa8 mcp rm NAME` removes a server's pins with it. ### Pins set by your organisation First use is taken on trust. An organisation that wants more than that puts the digests in its signed MCP policy, copied from `kosa8 mcp pins show` on a machine where someone reviewed the server: ``` { "server": "github", "allow_tools": ["list_issues", "create_issue"], "pins": [ { "tool": "list_issues", "digest": "sha256:<64 hex digits>" }, { "tool": "create_issue", "digest": "sha256:<64 hex digits>" } ] } ``` Once a server has any pins in the policy, those are the only pins that count for it. A tool the policy does not list, or whose definition differs, is withheld, and the hint changes to `the org policy's pins decide this; ask your admin`. Nothing can be approved locally. Each pin names one exact tool, and a digest that is not `sha256:` followed by 64 hex digits is refused when the policy is signed. How to sign and install a policy is in [Company rules](https://docs.kosa8.com/guides/governance/). ## Schema checks, in and out A tool's schema says what arguments it accepts. The gateway holds both sides to it. #### On the way in The assistant's arguments are checked against the pinned input schema before the server sees them. Arguments the tool did not ask for are refused: `kosa8: the arguments do not match the schema echo/echo was approved with: …` #### On the way out If the tool declared an output schema, its result is checked before the model reads it. A result outside the tool's own contract is replaced with: `kosa8: echo/echo returned a result that does not match the output schema it declared, so it was withheld: …` A schema is also untrusted input. One that points at something off your machine, recurses without making progress, or nests absurdly is refused when the gateway starts, and its tool is not offered: `tool "x" withheld: its input schema cannot be enforced: …`. A single check that takes longer than two seconds counts as failed. ## kosa8's own tools The same gateway serves thirty tools that drive kosa8 itself. List them with their descriptions at any time: ``` $ kosa8 mcp tools ``` | Group | Tools | |---|---| | Status | `kosa8_status` | | Containers | `container_run` `container_list` `container_logs` `container_exec` `container_stop` `container_stats` | | Images | `image_list` `image_pull` `image_build` | | Networks and volumes | `network_list` `network_create` `volume_list` `volume_create` | | Sandboxes | `sandbox_create` `sandbox_list` `sandbox_pull` `sandbox_run` `sandbox_code_run` `sandbox_start` `sandbox_stop` `sandbox_delete` | | Workspace files and git | `sandbox_file_list` `sandbox_file_read` `sandbox_file_write` `sandbox_file_delete` `sandbox_git` | | Shell sessions and previews | `sandbox_shell` `sandbox_session_delete` `sandbox_preview` | | Save and undo | `sandbox_checkpoint` `sandbox_checkpoint_list` `sandbox_rewind` `sandbox_snapshot` `snapshot_list` `snapshot_restore` `snapshot_rm` | | Copy and explore | `sandbox_fork` `sandbox_explore` | A sandbox the assistant creates with `sandbox_create` has **no network** unless the assistant asks for one. It can name the hosts it needs with `egress_allow`, or ask for an open network with `egress: "any"`. Either way the request is in the call's arguments, so it is in the audit entry and can be the subject of a rule. Pulling images still works with no network, because the service fetches images on your side. Where your organisation sets `default_egress`, that default applies instead. These tools can be governed like any third-party server by naming the server `kosa8` in your policy. See [Rules for tool calls](https://docs.kosa8.com/guides/tool-rules/). ## Which images may run as tool servers An organisation can list the images allowed to run as MCP servers, in the signed organisation policy: ``` { "name": "example-corp", "allowed_mcp": ["node:22-alpine", "ghcr.io/example-corp/docs-mcp:1.4"] } ``` The list is checked when you register a server and again when the gateway starts it. An image that is not on it, compared exactly and without regard to case, is refused: ``` policy "example-corp" forbids MCP image "alpine" (allowed: node:22-alpine, ghcr.io/example-corp/docs-mcp:1.4) ``` An empty list means any image. The full field list is in [Policy reference](https://docs.kosa8.com/guides/policy-reference/). ## What the audit log records Every call through the gateway to a third-party server is written to the audit log. An allowed call leaves two entries: the decision, and the result as a child of it. A refused call leaves the decision alone. Tools that were withheld or denied when the gateway started are recorded too, so "why did the assistant not have this tool" has an answer. | Entry | When | What it carries | |---|---|---| | `mcp.decision` | A tool is offered or withheld at start (`phase: expose`), or called (`phase: call`) | Server, tool, scope, allowed or not, the rule that decided, the policy's name, digest and signer, and the SHA-256 of the arguments. | | `mcp.result` | An allowed call returned | The same, plus the SHA-256 of the result, whether it was an error, and `parent_seq` pointing at the decision. | ``` "refs": { "session": "…", "mcp": { "phase": "call", "server": "echo", "tool": "echo", "scope": "eng", "allowed": true, "policy": "example-corp", "policy_digest": "sha256:…", "arguments": "…", "server_sandbox": "mcp-echo" } } ``` Arguments and results are named by digest and never stored. A tool call's arguments are whatever the assistant chose to send, which may be a query over customer records or a token it found, and the audit log is kept for years and forwarded to other systems. The digest lets someone who holds the arguments prove they are the ones that were sent. More in [The audit log](https://docs.kosa8.com/guides/audit/). #### What this does not do - **First use is trusted.** The definitions a server offers the first time are pinned as they are. If they were already hostile, the pin protects the hostile version. Organisation pins exist for this. - **Results are checked for shape, not for meaning.** A result that matches its schema can still contain text written to steer the model. Nothing here detects instructions hidden in well-formed output. - **Nothing scans content.** Descriptions, arguments and results are not searched for secrets, malware or prompt injection. A pin tells you a definition changed. Reading the difference is your job. - **A tool with no output schema returns anything.** The outgoing check applies only to tools that declared one. - **The gateway itself runs as you.** It is not in a sandbox. The servers are. Anything that can run commands as your user can also edit `mcp-servers.json` and `mcp-pins.json`; the terminal check on `pins approve` stops a pipe, and nothing more determined. - **Pins are checked when the gateway starts.** A server that changes what it offers mid-session is not asked again until the next start. Calls are still checked against the schema that was pinned. - **Values given with `-e` are in the server's hands.** They are stored in `~/.kosa8/mcp-servers.json`, readable only by you, and passed to the server process, which can send them to any host its network rule allows. - **Images are named by tag.** `allowed_mcp` compares image names, not image contents. ## Where to go next [`Rules for tool calls` Decide a call by its arguments: allow, deny, or ask a person first.](https://docs.kosa8.com/guides/tool-rules/) [`Agent identity` Give each agent its own token, scope and name in the audit log.](https://docs.kosa8.com/guides/agent-identity/) [`Company rules` Sign a policy and install it so a laptop cannot edit around it.](https://docs.kosa8.com/guides/governance/) [`Agent troubleshooting` Every refusal message, what it means and what to do.](https://docs.kosa8.com/guides/troubleshooting/) --- Source: https://docs.kosa8.com/guides/mcp-servers/ # Rules for tool calls A list of permitted tools says which tools an agent may use. It cannot say what the agent may use them *for*. Rules look at the arguments of each call and answer one of three things: allow, deny, or ask a person first. ## Why a tool list is not enough Take one tool, `create_issue`, on a GitHub tool server. With tool lists alone you have two choices. #### Tool lists only Put `create_issue` in `allow_tools` and the agent may open an issue in any repository its token can reach, including a public one that belongs to someone else. Leave it out and the agent cannot file an issue in your own repository either. #### With a rule Allow the tool, and add one rule: deny the call when `repo` is not under `acme/`. The agent files issues where you meant it to, and nowhere else. The only difference between the good call and the bad one was an argument. Rules are part of the same signed MCP policy as the tool lists. If you have not written one yet, start with [Company rules](https://docs.kosa8.com/guides/governance/), which covers keys, signing and installing. This page covers what goes inside. ## Three answers **allow**The call goes ahead. Mostly useful to name a rule in the audit log, because a call no rule matches goes ahead anyway once the tool lists have admitted it. **deny**The call is refused. The agent is told the rule's `reason`, so write it as the next step. **ask**The call is permitted in principle and someone should look at this one first. It is held until a person approves that exact call. When several rules match one call, **deny beats ask beats allow**, wherever each is written. A narrow prohibition survives any broader grant, before or after it. Within one effect, the first matching rule in the file is the one reported. > Diagram: A call passes the tool lists, then the argument rules, then the schema check. A deny from any of these refuses the call with a reason. If the outcome is allow, the tool runs at once. If it is ask, the call is held for a person, or for the optional local scorer, and runs only when approved. Every outcome is written to the audit log. Rules never widen. They are consulted only for a tool the tool lists already admit. 1. `allow_tools` and `deny_tools` decide whether the tool may be used at all. A tool they refuse never reaches the rules. 2. The server's rules test the arguments. A deny here is final. 3. The arguments are checked against the tool's pinned schema. See [Tool servers](https://docs.kosa8.com/guides/mcp-servers/). 4. If no rule objected, the call is allowed. 5. If a rule said ask, the call is held. A person approves it, or the optional local scorer judges it routine. 6. A deny from any step refuses the call and tells the agent why. 7. The tool runs, and its result is checked against the output schema if it declared one. 8. Exactly one decision is recorded per call, whatever it was. ## Writing a rule Rules live in the `rules` list of a server entry, inside a scope. Here is a whole policy with two of them. ``` { "name": "example-corp", "default_scope": "eng", "scopes": [ { "name": "eng", "servers": [ { "server": "github", "allow_tools": ["list_*", "get_*", "create_issue", "merge_*"], "rules": [ { "id": "only-acme", "tools": ["create_issue"], "when": [ { "arg": "/repo", "op": "not_glob", "value": "acme/*" } ], "effect": "deny", "reason": "issues go in acme repositories" }, { "id": "merge-needs-a-person", "tools": ["merge_*"], "effect": "ask", "reason": "merging changes what ships" } ] } ] } ] } ``` | Field | Required | Meaning | |---|---|---| | `id` | no | A short name for the rule, shown in decisions and audit entries. Must be unique within the server's rules. Without one the rule is called `rule[0]`, `rule[1]` and so on. | | `tools` | yes | Tool-name patterns the rule applies to. `*` stands for any run of characters. Names are the server's own (`create_issue`), not the prefixed name the agent sees. | | `when` | no | Conditions that must *all* hold. Leave it out and the rule applies to every call of the matched tools. | | `when[].arg` | yes | Which argument to test, as a JSON Pointer. See below. | | `when[].op` | yes | The operator. See the table below. | | `when[].value` | depends | What to compare against, typed per operator. `exists` and `absent` take none. | | `effect` | yes | `allow`, `deny` or `ask`. | | `reason` | no | What the agent or person is told. Without one, kosa8 writes a sentence naming the tool, the rule and the operator. Argument *values* are never quoted in it. | ### Six worked examples **Only in one place.** Because deny outranks allow, "only in acme" cannot be an allow beside a broad deny. It is a deny on everything else, written with a `not_` operator. This also stops a call that leaves `repo` out and lets the server pick a default. ``` { "id": "only-acme", "tools": ["create_issue"], "effect": "deny", "when": [ { "arg": "/repo", "op": "not_glob", "value": "acme/*" } ], "reason": "issues go in acme repositories" } ``` **Read-only SQL.** Deny a query that does not begin with `SELECT`. The value is an RE2 regular expression, which matches anywhere unless you anchor it. ``` { "id": "select-only", "tools": ["query"], "effect": "deny", "when": [ { "arg": "/sql", "op": "not_matches", "value": "(?i)^\\s*select\\b" } ], "reason": "this connection is for reading; use the migration tool to change data" } ``` A pattern is a tripwire, not a SQL parser: a statement can start with `SELECT` and still do something else after a semicolon. Give the server a read-only database account as well. **Ask before anything that merges.** No conditions, so it applies to every call of the matched tools. ``` { "id": "merge-needs-a-person", "tools": ["merge_*"], "effect": "ask", "reason": "merging changes what ships" } ``` **Cap a number.** The value is a JSON number, unquoted. ``` { "id": "limit-cap", "tools": ["search_*", "list_*"], "effect": "deny", "when": [ { "arg": "/limit", "op": "gt", "value": 1000 } ], "reason": "ask for at most 1000 rows at a time" } ``` **Keep out of a directory.** ``` { "id": "no-etc", "tools": ["read_file", "write_file"], "effect": "deny", "when": [ { "arg": "/path", "op": "prefix", "value": "/etc/" } ], "reason": "system configuration is off limits" } ``` The rule tests the string the agent sent, not where it leads. A relative path, or one that reaches `/etc` through `..` or a symlink, does not start with `/etc/`. Where that matters, turn the rule round: deny every path that does *not* start with the one directory you mean to allow, and add a second rule that denies any path that `contains` `..`. **Require an argument.** Deny the call when it is missing. ``` { "id": "branch-required", "tools": ["create_pull_request"], "effect": "deny", "when": [ { "arg": "/base", "op": "absent" } ], "reason": "name the base branch explicitly" } ``` ## Every operator There are 23. Comparison is typed: the string `"1"` never equals the number `1`, and numbers compare by exact value, so `1`, `1.0` and `1e0` are equal and very large integers do not lose precision. | Operator | Value | Holds when the argument… | If it is missing | |---|---|---|---| | `exists` | none | is present. An explicit `null` is present. | false | | `absent` | none | is not present. | true | | `eq` | string, number, boolean or null | is a single value of the same type and equal. | false | | `ne` | string, number, boolean or null | is a single value that `eq` would reject. | true | | `in` | non-empty array of single values | equals at least one entry. | false | | `not_in` | non-empty array of single values | equals no entry. | true | | `prefix` | string | is a string starting with the value. | false | | `suffix` | string | is a string ending with the value. | false | | `contains` | string | is a string containing the value. | false | | `glob` | string; `*` is any run of characters | is a string matching the whole pattern. Case-sensitive. | false | | `matches` | RE2 regular expression | is a string the expression matches anywhere. Anchor with `^` and `$` for the whole value. | false | | `not_prefix` | string | is a string that does not start with the value. | true | | `not_suffix` | string | is a string that does not end with the value. | true | | `not_contains` | string | is a string that does not contain the value. | true | | `not_glob` | string pattern | is a string that does not match the pattern. | true | | `not_matches` | RE2 regular expression | is a string the expression does not match. | true | | `lt` | number | is a number less than the value. | false | | `le` | number | is a number less than or equal to the value. | false | | `gt` | number | is a number greater than the value. | false | | `ge` | number | is a number greater than or equal to the value. | false | | `len_le` | non-negative integer | has length at most the value: characters of a string, elements of an array, keys of an object. | false | | `len_ge` | non-negative integer | has length at least the value, counted the same way. | false | | `type` | one of `string`, `number`, `integer`, `boolean`, `array`, `object`, `null` | has that JSON type. `integer` is any number with no fractional part (`1.0` counts), and every integer is also a `number`. | false | Regular expressions use RE2 syntax, which has no backreferences or lookaround and runs in time proportional to the input. A rule that needs "present *and* different" says so with two conditions: `exists`, then `ne`. ## Naming an argument `arg` is a JSON Pointer (RFC 6901): a path of names separated by `/`, starting from the arguments object. | Pointer | Refers to, in `{"repo":"acme/api","files":[{"path":"a.go"}],"a/b":1,"m~n":2}` | |---|---| | `/repo` | `"acme/api"` | | `/files/0/path` | `"a.go"`. Array elements are numbered from 0. | | `/files` | The whole array. Use `len_le` to cap how many files one call may touch. | | `/a~1b` | `1`. Inside a name, `~1` stands for a literal `/`. | | `/m~0n` | `2`. `~0` stands for a literal `~`. | | `/files/3/path` | Nothing. The argument counts as missing. | | the empty string | The whole arguments object. | A pointer that walks into something with no members, such as a name looked up inside a string, finds nothing, exactly like a key that is not there. An array index has no leading zeros: `/files/01` is not element 1. ## What if the agent sends the wrong type? Suppose a rule denies `/amount gt 1000`, and the agent sends `"amount": "5000"`, a string. A string cannot be compared with a number, so the condition is neither true nor false. kosa8 calls this *indeterminate*. Treating "could not tell" as "false" is the classic policy bug: the agent would get past a limit by adding quotation marks. So an indeterminate condition takes whichever side makes the outcome stricter. | Rule effect | Condition clearly true | Condition clearly false | Condition indeterminate | |---|---|---|---| | `deny` | rule fires | rule does not fire | fires the call is denied | | `ask` | rule fires | rule does not fire | fires the call is held | | `allow` | rule fires | rule does not fire | does not fire the allow is not earned | When a rule fires this way, the reason says so after the rule's own text, so nobody goes looking for a large amount that was never there: ``` kosa8: amounts over 1000 need finance [argument "/amount" could not be evaluated by "gt": it is a string, and this operator needs a number, so the rule was applied rather than risk letting the call through; send that argument in the type the tool's schema declares] ``` These make a condition indeterminate: - A value of a type the operator cannot test: a number given to `prefix`, a string given to `gt`, an array or object given to `eq`, a number given to `len_le`. - A string longer than 64 KiB given to `glob`, `matches` or their `not_` forms. - Arguments larger than 1 MiB, arguments that are not valid JSON or not valid UTF-8, arguments that are not an object, and arguments that repeat a key. Two programs can read a repeated key two ways, so no answer about it is trusted. One certain "false" still settles a rule: if any condition definitely fails, the rule does not fire, whatever its other conditions did. ## Try rules before enforcing them A new rule can break someone's work. Log mode decides and records exactly as enforcement would, and then lets the call through. Set it at the top of the policy: ``` { "name": "example-corp", "rules_mode": "log", "default_scope": "eng", "scopes": [ … ] } ``` The audit entry for a call that a rule would have stopped is marked allowed, and its detail says what enforcement would have done and why: ``` call allowed for reviewer (scope eng): log mode: would deny — issues go in acme repositories ``` A rule that would have asked reads `log mode: would ask — …`, and the call is not held. Run in log mode for a while, read the entries (`kosa8 mcp-policy events --format table`), then remove the line or set `"rules_mode": "enforce"` and sign again. **Log mode softens rules only** `allow_tools`, `deny_tools`, pinned tool definitions and schema checks still refuse in log mode. They are not new, so there is nothing to try out. ## Rules for kosa8's own tools kosa8's own tools (`sandbox_create`, `container_run` and the rest) are governed by naming a server called `kosa8`. The lists and rules work exactly as they do for a third-party server. ``` { "server": "kosa8", "deny_tools": ["sandbox_delete", "snapshot_rm"], "rules": [ { "id": "approved-images", "tools": ["container_run", "sandbox_run"], "when": [ { "arg": "/image", "op": "not_in", "value": ["alpine", "node:22-alpine", "python:3.13-slim"] } ], "effect": "deny", "reason": "run one of the approved images" }, { "id": "open-network-needs-a-person", "tools": ["sandbox_create"], "when": [ { "arg": "/egress", "op": "eq", "value": "any" } ], "effect": "ask", "reason": "an unrestricted network is reviewed first" } ] } ``` Two things differ from a third-party server. - **Silence means "leave them alone".** A scope that does not list `kosa8` does not govern these tools at all, and nothing extra is recorded for them. Policies written before this existed said nothing about kosa8 itself, and reading that silence as "deny" would have taken the runtime away from everyone they govern. Third-party servers are the opposite: one that is not listed is denied. - **They are refused when called, not hidden.** A denied tool of kosa8's own still appears in the tool list; calling it returns the refusal. Once a scope does list `kosa8`, the usual tool-list behaviour applies: with an `allow_tools` list, any tool not on it is denied. ## The ask flow MCP has no dependable way to put a question to a person in the middle of a call, so the gateway holds the decision itself. > Diagram: A sequence between an agent, the kosa8 gateway and a person. The agent calls a tool. The gateway refuses and returns an id for the held call. The person runs kosa8 mcp asks to read the call, then kosa8 mcp approve with the id. The agent makes exactly the same call again and it runs once. A further identical call is held again. The approval is for one call, not for the tool. 1. The agent calls a tool that an `ask` rule matches. 2. The gateway refuses for now, and returns an id that names this exact call. 3. A person lists what is waiting and reads the arguments the agent sent. 4. They approve that id, at a terminal. 5. The agent repeats the call with the same arguments. 6. It runs, and the approval is used up. 7. The same call a third time asks the question again. What the agent is told, word for word, with the rule's reason first: ``` kosa8: deploys are reviewed — this call needs a person's approval and you cannot give it. Ask a person to review it with `kosa8 mcp asks` and approve it with `kosa8 mcp approve 3f2a9c0d1e4b5a67`, then make exactly the same call again ``` What the person does: ``` $ kosa8 mcp asks 3f2a9c0d1e4b5a67 pending echo/echo by agt_1a2b3c4d5e6f why: deploys are reviewed args: { … the arguments exactly as the agent sent them … } until 15:04:05 $ kosa8 mcp approve 3f2a9c0d1e4b5a67 approved echo/echo (held call 3f2a9c0d1e4b5a67) the agent can now make the same call again; the approval is good for one use, for ten minutes ``` To say no, use `kosa8 mcp refuse ID`. The agent's next attempt is answered `kosa8: a person refused this call (held call ID): …`, and keeps being answered that way until the hour the question was given runs out, so asking again does not raise a fresh question. ### What an approval is bound to | Property | Value | |---|---| | Bound to | The agent's verified identity, the server, the tool, and the SHA-256 digest of the arguments. Change any one and it is a different question with a different id. | | Uses | One. Running the call spends it. Two identical calls racing for one approval cannot both win. | | Approval lifetime | Ten minutes from the moment it is given. | | How long a question waits | One hour. After that the agent must make the call again to ask afresh. | | Who may decide | Someone at a terminal. Through a pipe the command answers kosa8 mcp approve` must be run by a person at a terminal`. | | Arguments shown | In full, with terminal control sequences removed, up to 64 KiB. Beyond that only their digest is shown. | The agent identity in that list is the one from [Agent identity](https://docs.kosa8.com/guides/agent-identity/). A gateway with no identity shows as `(unidentified agent)`, and an approval given to one unidentified gateway fits any other. ## The optional local scorer People asked too often stop reading and approve by reflex, and then the control has stopped working while still looking present. A policy can let a small model on your own machine answer the routine questions, so the ones that reach a person deserve attention. ``` "classifier": { "scorer": "local-model", "endpoint": "http://127.0.0.1:7434", "model": "qwen", "threshold": 0.95, "timeout_ms": 2000 } ``` | Field | Meaning | Checked when the policy is signed | |---|---|---| | `scorer` | The kind of scorer. | Must be `local-model`, the only kind there is. An unknown kind is refused rather than ignored. | | `endpoint` | The base URL of an OpenAI-compatible model server. | Must be `http://` on `localhost` or a loopback address. Tool arguments must not leave the machine. | | `model` | The model name to ask for, as the local model server knows it. | Required. A classifier with no model is refused. | | `threshold` | How confident an "allow" must be to be acted on. | Above 0.5 and below 1. There is no default: how sure is sure enough is the decision this field records. | | `timeout_ms` | The longest one question may take. Optional; 0 means two seconds. | Between 0 and 30000. | ### What it can and cannot do - **It only ever answers an ask.** It is consulted for a call a rule said to ask about, when no person has answered yet. It is never shown a call the policy denies, and it has no answer that means deny. - **The best it can do is skip a question.** A confident "allow" lets the call run, recorded as `auto-allowed by … at confidence 0.97 (threshold 0.95), in place of asking a person`. Anything else leaves the question with a person, exactly as if there were no scorer. - **It fails closed.** An error, a timeout, an answer that is neither of the two, a missing probability, or a confidence under the threshold all mean "ask a person". ### The veto that runs first Before the model is asked anything, a fixed set of checks reads the call. If any fires, the question goes straight to a person and the model never sees the arguments. These checks are code, not a model, so they cannot be argued with. | Class | What trips it | |---|---| | `destructive` | Verbs that make data or a running thing go away, in the tool name or the arguments, and command shapes such as `rm -rf`, `--force`, `--hard`, `kill -9`, `chmod 777` or piping into a shell. | | `credential` | Values in the published shape of a secret (API keys, GitHub and Slack tokens, AWS key IDs, private keys, JWTs), long random-looking strings, and argument names such as `password` or `api_key`. | | `sensitive-path` | Paths into the system or credential stores, `.env` files, and `..` as a path element. | | `external-destination` | Anything naming somewhere other than this machine: a URL, a hostname, an IP address, an email address. | | `prompt-injection` | Text addressed to a reviewer rather than a tool, chat-template control tokens, and hidden or direction-changing characters. | | `size` | Arguments too large, too deeply nested, or holding too many strings to inspect. | | `malformed` | Arguments that are not valid JSON, or that repeat a key. | The veto errs towards asking. Any commit hash looks like a token to it, so those calls reach a person. ### What it needs The scorer reads the model's answer as a probability, which requires the model server to return token log-probabilities. `kosa8 model serve NAME --backend llamay` passes them through from [llamay](https://www.llamay.com). kosa8's own inference engine has none to give, so through it the scorer never auto-allows and every ask goes to a person. See [Local models](https://docs.kosa8.com/guides/models/). ## Sign it and install it Rules take effect like any other change to the MCP policy: sign the file with your organisation's key and install the signed copy. The key, the pinning step and the reasons are in [Company rules](https://docs.kosa8.com/guides/governance/). ``` $ kosa8 mcp-policy sign key.txt policy.json > signed.json $ kosa8 mcp-policy install signed.json ``` Signing validates every rule first, so a typo does not become a deny that silently never fires: ``` refusing to sign: scope "eng", server "github": only-acme: condition 0 ("/repo" "globb"): unknown operator; see the operator table in the package documentation ``` A running gateway reads the policy once, when it starts, so restart `kosa8 mcp serve` after installing. Try a rule before an agent meets it. `kosa8 mcp-policy check SERVER TOOL` answers for the tool lists; add `--args` with the call's arguments as JSON and the server's rules are applied to them, exactly as the gateway would: ``` $ kosa8 mcp-policy check github create_issue --args '{"repo":"evil/web"}' DENIED: github/create_issue for scope eng effect with these arguments: deny issues go in acme repositories rule: eng/github rules "only-acme" $ kosa8 mcp-policy check github create_issue --args '{"repo":"acme/web"}' allowed: github/create_issue for scope eng effect with these arguments: allow $ kosa8 mcp-policy check github create_issue --args '{}' # no repo at all DENIED: github/create_issue for scope eng effect with these arguments: deny $ kosa8 mcp-policy check github merge_pull_request --args '{"pr":41}' ASK: github/merge_pull_request for scope eng effect with these arguments: ask merging changes what ships ``` It exits non-zero for a deny or an ask, so a policy change can be tested in CI before it is signed. | Limit | Value | |---|---| | Rules per server entry | 512 | | Conditions per rule | 16 | | Tool patterns per rule | 64 | | A pattern or regular expression | 512 bytes | | A `value` | 4096 bytes; a list holds at most 256 entries | | A rule `id` | 128 bytes | #### What this does not do - **Rules see only the arguments the agent chose to send.** A deny on `/path` does nothing about a server that also accepts `/file`. The schema check helps only if the tool's schema forbids extra arguments. - **Rules test text, not meaning.** They do not resolve paths, parse SQL, or follow a URL. A pattern can be satisfied by a call that still does harm. - **An approval is a local file.** It is decided by a command your user runs. Requiring a terminal stops an agent approving its own call through a pipe, and nothing more determined. An agent that can run commands as you, outside a sandbox, is beyond what a local file can prevent; signed approvals from named people are the control for that. - **The scorer can be talked round.** It reads text an attacker may have chosen. Its confidence is not calibrated, and a threshold has to be tuned against your own team's calls, in log mode, before it is trusted. Where a wrong "allow" is unaffordable, write a deny. - **The veto is a tripwire.** It recognises the shapes listed above and nothing else. A destructive action phrased politely passes it. - **Deleting the policy file lifts its rules on kosa8's own tools.** With a signer pinned and `mcp-policy.json` removed, every third-party server is denied, which is noticed, but kosa8's own tools go back to ungoverned. A policy that is present and fails verification denies both. - **Nothing reads tool results for hidden instructions.** Rules are about what goes in. ## Built-in rules for an agent's own tool calls Everything above is about tools served through kosa8's MCP gateway. An agent's own tools — Claude Code's `Bash`, `Write` and `Edit`, Codex's `shell`, Gemini's `run_shell_command` — get a fixed set of rules instead. They are checked before each call runs when the kit has a pre-run hook (the `claude` kit does), and against every call the model asks for in the audit log. A shell command is parsed, not pattern-matched: `cd repo && git push -f`, `bash -c 'git push -f'`, `sudo -u ci git push -f`, `git -C repo push -f` and `find . -exec git push -f \;` are all the same force push. | answer | rules | |---|---| | refused | K8-D001 a download run as code (`curl … \| sh`, `sh -c "$(curl …)"`) · K8-D002 a write to the agent's own settings or hooks, or kosa8's · K8-D003 credential files sent off the machine | | asks you | K8-A001 force push or remote delete · A002 cloud or cluster destroy · A003 publishing a package or image · A004 PR merge, repo delete, API DELETE · A005 reading credentials or the whole environment · A006 destructive SQL · A007 `git reset --hard`, `git clean -fdx`, deleting `.git` or the workspace · A008 code piped into an interpreter from something the rules cannot read · a command that does not parse | | runs, marked | K8-F001 network tools · F002 package installs · F003 git push · F004 sudo · F005 a program named by a variable · F006 inline interpreter code | ### How often they catch it The rules are measured against a hand-labelled set of tool calls (`pkg/rules/testdata/corpus.jsonl`): 75 that a person should see before they run, and 89 of the ordinary work an agent does all day. The risky set deliberately includes evasions. Today: - **66 of 75 risky calls stopped (88.0%)** — refused or held for you. - **1 of 89 ordinary calls stopped (1.1%)**: `env | grep -c PATH`, which reads the whole environment to count one line. The nine it misses, and why: a `make` target and a script file (what they run is not in the call); an alias and a program held in a variable (`G=git; $G push -f`); code inside a Python or Node one-liner that shells out or writes a file; a download saved to a file and then run as a second command; and credentials tarred and piped to an upload. A rule reads the call it is given — it cannot see inside a file or a language it does not parse. That is why kosa8 does not rely on the rules alone: the sandbox still bounds what any call can reach, every call the model asked for is recorded whether or not a rule caught it, and a rewind undoes what happened inside it. The test fails if either rate gets worse; `go test ./pkg/rules -run Corpus -v` lists every miss. ## Where to go next [`Agent identity` Different rules for different agents, and approvals that belong to one of them.](https://docs.kosa8.com/guides/agent-identity/) [`Tool servers (MCP)` Running third-party servers in sandboxes, with pinned definitions.](https://docs.kosa8.com/guides/mcp-servers/) [`Company rules` Keys, signing, installing, and what tampering does.](https://docs.kosa8.com/guides/governance/) [`Agent troubleshooting` Every refusal message, what it means and what to do.](https://docs.kosa8.com/guides/troubleshooting/) --- Source: https://docs.kosa8.com/guides/tool-rules/ # Agent identity Without an identity, every agent on a machine is the same agent, and "agent" in the audit log is a word the caller chose for itself. An agent identity is a name, a policy scope and a token that kosa8's service checks on every request. ## Why a label is not an identity When an AI client drives kosa8, each request it sends is marked as coming from an agent. That mark is a claim. Nothing checks it, and nothing in it says *which* agent. Two things an organisation needs are impossible while that is all there is. #### A self-declared label - The reviewer bot and the release bot get the same tools, because policy has one default scope for "an agent". - The audit log says an agent did it. It cannot say which. - An approval given to one agent's call fits another agent making the same call. #### A verified identity - Each agent acts in its own scope, chosen from its record and never by the caller. - Every audit entry carries the agent's id, name, scope and owner. - An approval belongs to the agent that asked. ## How it works > Diagram: A person mints a token with kosa8 agent token create. The token is shown once and given to the agent's gateway in the KOSA8_AGENT_TOKEN environment variable. The gateway presents it on every request to the kosa8d service. The service compares it with the digest in its registry, finds the record, takes the policy scope from that record, and writes the agent's id, name and scope into every audit entry. The token proves which record. The record decides what the agent may do. 1. A person creates the identity. An agent cannot. 2. The token is printed once. kosa8 keeps only a SHA-256 digest of it. 3. The agent's gateway is started with the token in its environment. 4. The service receives the token with each request, hashes it, and looks for the match. 5. A match gives a record: who this is, when it expires, whether it was revoked. 6. Tool calls are decided in the scope written in that record. 7. The audit entry for the request names the agent, by an id that never changes. ## Walkthrough ### Mint an identity ``` $ kosa8 agent token create reviewer --scope eng --owner "Jane Doe " --ttl 1h agent reviewer (agt_1a2b3c4d5e6f), scope eng, expires 2026-… owner Jane Doe the token is shown once and is not stored; give it to the agent's gateway as KOSA8_AGENT_TOKEN k8a_… ``` The lines of explanation go to standard error and the token goes alone to standard output, so a script can capture it without it passing through a terminal's scrollback. | Flag | Meaning | Default | |---|---|---| | `--scope` | The MCP policy scope the agent acts in. | The policy's `default_scope` | | `--owner` | The person accountable for what the agent does, as `"Name "`. | Your own kosa8 identity (`kosa8 identity init`), with its key fingerprint. With neither, no owner is recorded and the command says so. | | `--ttl` | How long the token works, as a duration such as `8h` or `720h`. | 24 hours. At most 8760h, which is 365 days. | The owner is fixed when the identity is minted. To hand an agent to someone else, revoke it and mint a new one under the new owner, so every audit entry names the person who was accountable at the time it was written. A name is 1 to 64 characters from letters, digits, `-`, `_` and `.`. Names appear in audit entries and queries, so they are identifiers, not sentences. ### Give it to the agent's gateway The gateway is the `kosa8 mcp serve` process your AI client launches. Put the token in its environment as `KOSA8_AGENT_TOKEN`. It is read from the environment and not from a flag so that it never shows up in a process listing. ``` $ KOSA8_AGENT_TOKEN=k8a_… kosa8 mcp serve kosa8 MCP gateway: AI session … kosa8 MCP gateway: serving agent reviewer (agt_…), scope eng ``` The gateway asks the service who the token is before it does anything else. If the service does not accept it, the gateway does not start. ### See who exists ``` $ kosa8 agent token ls ID NAME SCOPE OWNER STATE EXPIRES ``` One row per identity, newest first. `STATE` is `live`, `expired` or `revoked`. A scope shown as `(default)` means the identity follows the policy's default scope. Tokens are never listed, because kosa8 does not have them. ### Take it away ``` $ kosa8 agent token revoke reviewer revoked reviewer ``` Revoke by name or by id. The record stays, marked as revoked, so old audit entries that name the id still lead somewhere. The service checks the registry on every request, so the next request with that token is refused. ## What changes once a gateway is identified **Scope**Which tool servers and tools the agent may use, and which rules apply, are decided in the identity's scope. Not the policy default, and never a scope the caller asks for. **Audit**Every request the gateway makes is recorded with `refs.agent`: the id, name, scope and owner the service verified. That covers tool decisions, tool results, sandboxes it creates, and everything else. **Approvals**A call held by an `ask` rule is held for this agent. An approval is bound to its id and cannot be spent by another agent making the same call. See [Rules for tool calls](https://docs.kosa8.com/guides/tool-rules/). **HTTP clients**With `--http`, and no `KOSA8_MCP_TOKEN` set, clients authenticate to the gateway with the same agent token (`Authorization: Bearer …`). One credential names the agent at both ends, and revoking it closes both. ## A different scope for each agent Here are two agents with different jobs. The reviewer reads. The release bot may merge, and only after a person has approved that exact merge. > Diagram: Two gateways on one machine. The first holds the reviewer's token and acts in the review scope, where only read tools are listed. The second holds the release bot's token and acts in the release scope, where merging is allowed after a person approves the exact call. Both scopes are in one signed policy. Neither agent can ask for the other's scope. The scope is in the record its token leads to. 1. The reviewer's gateway presents the reviewer's token. 2. The service finds its record and applies the scope written there. 3. That scope lists read tools, so those are the only ones the reviewer is shown. 4. The release bot presents a different token and lands in a different scope. 5. There, merging is permitted behind an `ask` rule. The policy: ``` { "name": "example-corp", "default_scope": "review", "scopes": [ { "name": "review", "description": "read-only agents", "servers": [ { "server": "github", "allow_tools": ["list_*", "get_*", "search_*"] } ] }, { "name": "release", "description": "may merge, with a person's approval", "servers": [ { "server": "github", "allow_tools": ["list_*", "get_*", "merge_*"], "rules": [ { "id": "merge-needs-a-person", "tools": ["merge_*"], "effect": "ask", "reason": "merging changes what ships" } ] } ] } ] } ``` The two identities, minted by a person: ``` $ kosa8 agent token create reviewer --scope review --ttl 8h $ kosa8 agent token create release-bot --scope release --ttl 8h ``` Each gateway is started with its own token. Making the least capable scope the default means a gateway with no identity gets the least. **Check the scope name** Minting checks that a scope is a well-formed name, not that the policy defines it. An identity whose scope the policy does not define has every call denied, with a message that lists the scopes that do exist. `kosa8 mcp-policy scopes` prints them. ## Requiring identity By default a gateway with no token still works, as the unidentified agent it always was. An organisation can end that with one field in the signed organisation policy: ``` { "name": "example-corp", "require_agent_identity": true } ``` Sign and install it as described in [Company rules](https://docs.kosa8.com/guides/governance/) (`kosa8 policy sign`, then `kosa8 policy install`). Under it, two things are refused. | What | What it is told | |---|---| | Starting `kosa8 mcp serve` with no token | `the org policy requires agents to identify themselves: mint a token with `kosa8 agent token create NAME` and start this gateway with KOSA8_AGENT_TOKEN set to it` | | Any request to the service that says it is from an agent and carries no token | HTTP 401: `the org policy requires agents to identify themselves, and this request carries no agent token` | Commands you type are not marked as an agent's and are unaffected. If the organisation policy is present and fails verification, the requirement is treated as set: a requirement that cannot be read is not assumed to be absent. ### Requiring an owner To make every agent name the person accountable for it, set `require_agent_owner`. It implies `require_agent_identity`, because an agent with no identity has no owner either. ``` { "name": "example-corp", "require_agent_owner": true } ``` | What | What it is told | |---|---| | Minting an identity with no `--owner` and no kosa8 identity of your own | `the org policy requires every agent to name an owner` | | A request from an identity minted without an owner, before the policy was installed | HTTP 401: `the org policy requires every agent to name an owner, and agent NAME (ID) has none`. Revoke it and mint it again with `--owner`. | The owner is what the person minting the identity said, or their own kosa8 identity. It is recorded, not signed by the owner, so it says who was named as accountable, not that they agreed to it. ## How tokens are kept | Question | Answer | |---|---| | What is stored? | A SHA-256 digest of the token, in `~/.kosa8/agents.json`, readable only by you. A copy of that file in a backup or a support bundle is not a set of working credentials. | | Can I see a token again? | No. It is shown once. A lost token is replaced, not recovered. | | What does a token look like? | `k8a_`, the identity's twelve hex digits, and a 256-bit random secret. The id on its own is `agt_` and the same twelve digits. | | How do I rotate one? | Revoke the name, then create it again. One name can have only one live token: creating a second is refused with `an agent named "reviewer" already has a live token (agt_…); revoke it first with `kosa8 agent token revoke reviewer. | | What happens at expiry? | The token stops working. Create a new one. There is no "never": the longest lifetime is 365 days, and asking for more answers `a token may live at most 365 days; rotate longer-lived identities`. | ### A bad token is refused, not ignored A request that presents a token which is wrong, expired or revoked is answered with HTTP 401. It is never served as if it had presented no token at all. If it were, revoking an identity would leave behind a working anonymous one, and the revoke would have revoked nothing. ``` agent token is not recognised; mint one with `kosa8 agent token create NAME` agent reviewer (agt_…) was revoked on 2026-…; mint a new token with `kosa8 agent token create` agent reviewer (agt_…) expired on 2026-…; mint a new token with `kosa8 agent token create` ``` A gateway started with such a token prints the same sentence after `the daemon did not accept the agent token:` and exits. An unknown id and a wrong secret get the same answer on purpose: saying "that id exists" would be telling the caller half of a credential. ### An agent cannot mint identities An identity carries a scope. An agent that could mint itself a second identity could pick a wider scope than the one it was given. So a request to create an identity that arrives with an agent token, or marked as an agent's, is refused with HTTP 403: `an agent cannot mint agent identities`. ## What the audit log shows An entry made by an identified agent has the actor `agent` and a `refs.agent` object. An entry with the actor `agent` and no `refs.agent` is now visibly an unverified claim: something said it was an agent and proved nothing. #### Verified ``` "actor": "agent", "action": "mcp.decision", "refs": { "agent": { "id": "agt_1a2b3c4d5e6f", "name": "reviewer", "scope": "eng" }, "mcp": { "phase": "call", … } } ``` #### Unverified claim ``` "actor": "agent", "action": "mcp.decision", "refs": { "mcp": { "phase": "call", … } } ``` Refused attempts are recorded as well, as `agent.auth.refused` with status 401 and the reason. Read entries with `kosa8 audit ls --actor agent`, or `kosa8 audit ls --json` for the `refs`. More in [The audit log](https://docs.kosa8.com/guides/audit/). ## Licensing Minting an identity needs the Governance subscription (or a trial started before kosa8 stopped offering them). Listing and revoking never do: a lapsed subscription must never be the reason an identity could not be taken away. #### What this does not do - **Tokens are bearer tokens.** Whoever holds the token is that agent. It is not tied to a process, a machine or a sandbox. Keep lifetimes short and revoke what you no longer use. - **There is no single sign-on.** Identities are created with a command on each machine and are not connected to a company directory. - **It identifies an agent to the service. It does not stop a process pretending to be you.** The service's socket belongs to your user, and anything running as you can call it without a token, as a person would. What changes is that such a call cannot carry an agent's identity, and that an agent entry with no identity is visible as an unverified claim. - **The rule against agents minting identities trusts the same mark.** It stops an agent's own gateway. It does not stop a process that lies about being an agent. - **The token is in the gateway's environment.** A program that can read that environment can copy it. Run the agent itself in a sandbox, where the gateway's environment is out of reach. - **A revoke stops a running gateway at its next call, not mid-call.** The gateway records each decision with the service before it runs the tool; once the service rejects the identity, the gateway refuses the call (`this gateway's agent identity (…) is no longer accepted by the daemon`). A tool call that was already running when you revoked finishes. Stop the gateway too if that matters. ## Where to go next [`Rules for tool calls` What goes inside a scope: tool lists, argument rules and asks.](https://docs.kosa8.com/guides/tool-rules/) [`Tool servers (MCP)` The gateway an identity is given to, and the servers behind it.](https://docs.kosa8.com/guides/mcp-servers/) [`The audit log` Reading entries, and proving nobody edited them.](https://docs.kosa8.com/guides/audit/) [`kosa8 agent token create` The command reference, generated from the binary.](https://docs.kosa8.com/reference/agent-token-create/) --- Source: https://docs.kosa8.com/guides/agent-identity/ # 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. **image**The container image the agent runs in, for example `node:22-alpine`. **packages, setup**What is installed before the agent starts. `packages` are pinned to an exact version and a digest; `setup` is free-form shell for whatever a package list cannot express. **workspace**The one folder on your computer the agent sees, and where it appears inside the sandbox. Nothing else on your computer is visible. **egress, egress_allow**Which hosts the sandbox can reach. Everything else is refused on your side of the boundary. **credentials, secrets**Which API keys the agent can use. `credentials` stay on your computer and the sandbox holds a placeholder; `secrets` are copied in as they are. ## What `kosa8 agent run` does > Diagram: Seven steps in order. Load the kit and check its signature. Refuse if the kit is marked unavailable or its setup installs something unpinned. Start the sandbox and its egress broker. Pull the image into the sandbox. Install the pinned packages, checking each digest. Run the command in the workspace folder. Remove the sandbox, however the run ended. Nothing the kit says is acted on until the kit itself has been checked. 1. The kit is loaded: a built-in, a file in `~/.kosa8/kits`, or a path. A file is checked against its signature. 2. The run stops here if the kit is marked `unavailable`, or if `setup` installs or downloads something that is not pinned. 3. kosa8 creates a fresh sandbox with the workspace mounted and, when the kit has `credentials`, an egress broker holding the real keys. 4. The image is pulled into that sandbox. The service fetches it on your side, so the sandbox needs no network for this. 5. Each pinned package is downloaded, its digest is compared with the one in the kit, and only then is it installed. 6. The kit's `command` runs in the workspace folder, and its output is streamed back to your terminal. 7. 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-slim) 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-slim 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. | **The command is run by a shell** 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](https://docs.kosa8.com/guides/credentials/). ## 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. | **Flags go before the kit name** 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](https://docs.kosa8.com/reference/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/claude-code@2.1.296 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" ``` 1. `npm pack` downloads the exact tarball to `/tmp`. 2. `sha512sum -c` compares it with the digest in the kit. 3. `npm install -g` installs 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. > Diagram: The kit file holds a version and a sha512 digest. npm pack downloads the tarball the registry serves for that version. sha512sum compares the tarball with the kit's digest. If they match, that file is installed. If the registry served different bytes, the check fails, the run ends and the agent never starts. The kit decides what the right bytes are. The registry only delivers them. 1. The kit carries the digest of the tarball you reviewed. 2. At run time the tarball is downloaded again, from the registry, inside the sandbox. 3. Before anything is installed, the download is hashed and compared with the kit. 4. The same bytes: that file is installed and the run goes on. 5. 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](https://docs.kosa8.com/guides/governance/), 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. 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-alpine` is 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. - **`secrets` are 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](https://docs.kosa8.com/guides/policy-reference/) describes. ## Where to go next [`Keep API keys out` How a brokered credential works, and what a route refuses.](https://docs.kosa8.com/guides/credentials/) [`Network rules` What `egress_allow` means on the wire, and where it is enforced.](https://docs.kosa8.com/guides/network/) [`Run an AI assistant` Using the built-in kits day to day.](https://docs.kosa8.com/guides/agents/) [`Policy reference` The signing key, pinning it, and what fails closed.](https://docs.kosa8.com/guides/policy-reference/) --- Source: https://docs.kosa8.com/guides/kits/ # Agent troubleshooting kosa8 refuses things on purpose, and each refusal says why. This page lists the messages word for word, grouped by where you meet them, with what each one means and what to do next. Search the page for a few words of the message you have. The command line prints an error after the prefix `kosa8:`. Most examples below leave that prefix off and show the message itself. Where a message has a … in it, the real one carries a detail that differs from case to case. ## How to see what happened A refusal is shown to whoever caused it, which is often the agent and not you. These commands show you the same thing afterwards. > Diagram: Five symptoms, each joined to the command that explains it. A request from the sandbox failed: kosa8 sandbox egress NAME. A tool call was refused: kosa8 audit ls with the actor set to agent. A tool call is waiting: kosa8 mcp asks. A tool is missing from the list: kosa8 mcp pins diff SERVER. Policy said no and you want the rule: kosa8 mcp-policy events with the denied flag. Start from the symptom on the left. | Command | What it shows | |---|---| | `kosa8 audit ls` | Recent audited actions, 25 by default. `-n 100` for more, `--actor agent` (or `human`, `tool`) to filter. | | `kosa8 audit ls --json` | The same entries in full, including `detail` and the structured `refs`: which agent, which rule, which host and path. | | `kosa8 audit verify` | Checks that the log has not been altered: `audit chain intact: 17 entries verified`. | | `kosa8 sandbox egress NAME` | The network rule your computer enforces for that sandbox, and counts of what passed and what was refused. | | `kosa8 mcp asks` | Tool calls waiting for a person, with the arguments as the agent sent them. | | `kosa8 mcp pins diff SERVER` | What changed in a tool server's withheld definitions. | | `kosa8 mcp-policy events` | MCP policy decisions. `--denied` for denials only, `-n 20` for the latest, `--format table` to read (the default is `jsonl`; `cef` is there too). | ``` $ kosa8 sandbox egress dev policy: allow:registry.npmjs.org (enforced on the host) frames: 3 passed, 0 denied, 1 dropped as link noise dns: 2 answered, 0 refused ``` Refused network requests appear in the audit log as `sandbox.egress.deny`, with the reason in the `detail` field: ``` sandbox.egress.deny {"kind":"http","host":"neverssl.com","port":80,"method":"GET","path":"/","reason":"host not in allow-list"} sandbox.egress.deny {"proto":"dns","dst":"8.8.8.8","port":53,"name":"example.com","reason":"name not in egress policy deny"} ``` ## Starting an agent Messages from `kosa8 agent run`, before the agent itself starts. Add `--dry-run` to see what a kit would do without creating anything. ### The kit is unavailable ``` 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`) ``` **What it means.** The built-in kit would have installed software kosa8 cannot vouch for, so it has been switched off rather than guessed at. `kosa8 agent kits` marks these `UNAVAILABLE`. **What to do.** Copy the kit with `kosa8 agent init KIT`, point it at a source you have checked yourself, and pin the package. See [Writing a kit](https://docs.kosa8.com/guides/kits/). ### The kit installs something that is not pinned ``` kit team installs software that is not pinned to a version and digest: …the setup lines in question… list it under `packages:` with a version and integrity, or pass --allow-unpinned to run whatever the registry serves today ``` **What it means.** The kit's `setup:` downloads or installs something by name alone. What you get would be whatever the registry serves at that moment. **What to do.** Move the install into the kit's `packages:` list with an exact version and its integrity digest. `--allow-unpinned` runs it anyway, and means what it says. ### A package's checksum did not match ``` sha512sum: WARNING: 1 of 1 computed checksums did NOT match ``` **What it means.** The package that was downloaded is not the one the kit pinned. The run ends before the agent starts. Either the kit's digest is wrong, or the registry served different bytes for that version. **What to do.** Do not work around it. Compare the version and integrity in the kit's `packages:` with what the publisher lists. If the kit is right, treat the download as suspect. ### The kit file is unsigned ``` kosa8: warning: kit file ./team.yaml is unsigned: nothing vouches for its contents (sign it with `kosa8 agent sign`, and pin the key with `kosa8 policy trust`) kosa8: warning: kit file ./team.yaml replaces the built-in kit of the same name ``` **What it means.** A warning, not a refusal. No organisation key is pinned on this machine, so the kit runs, and kosa8 tells you nobody has vouched for it. The second line appears when a file of yours has the same name as a built-in kit and is used in its place. **What to do.** Read the kit before running it. To make this a guarantee, sign kits with `kosa8 agent sign PRIVATE_KEY_FILE KIT_FILE` and pin the public key. ### The kit is not signed, on a machine that requires it ``` kit ./team.yaml is not signed, and this machine only runs kits signed by its org's key; have it signed with `kosa8 agent sign` kit ./team.yaml does not match its signature: it was changed after it was signed, or signed with a different key kit ./team.yaml is signed by a key this machine does not trust ``` **What it means.** Your organisation has pinned a signing key, so only kit files signed by it will load. The first message is a kit with no signature file beside it. The second is a kit that was edited after signing: the signature covers the exact bytes. The third is a valid signature by somebody else's key. **What to do.** Ask whoever holds the organisation's key to sign the file as it is now. Editing a signed kit always means signing it again. ### A key is not set on your computer ``` kosa8: warning: ANTHROPIC_API_KEY is not set on the host; the agent may fail to authenticate ``` **What it means.** The kit expects an environment variable that is empty in the shell you ran the command from. The sandbox starts anyway. **What to do.** Export the variable in that shell and run again. With a brokered kit the value stays on your computer. See [Keep API keys out](https://docs.kosa8.com/guides/credentials/). ### The service did not start a broker ``` the daemon did not start an egress broker for this sandbox; restart it so it matches this kosa8 (`kosa8 daemon stop && kosa8 daemon start`) ``` **What it means.** The background service is older than the command you typed and does not know about brokered credentials. The sandbox it made was removed again, because it would have had no way to reach anything. **What to do.** Run the two commands in the message. ### Notices worth reading ``` kosa8: passing GITHUB_TOKEN into the sandbox as plain environment variables — the agent can read them kosa8: network UNRESTRICTED — this kit sets no egress_allow ``` **What they mean.** Not errors. The first says a real key is inside the sandbox, because that tool cannot be pointed at the broker. The second says the kit names no hosts, so the sandbox can reach anything. **What to do.** For the first, use a token with as little access as the job needs. For the second, add `egress_allow` to the kit, or run with `--no-network`. ## Inside the sandbox What the agent, or a tool it runs, sees when the network says no. A brokered sandbox has one way out: the proxy named in its `HTTPS_PROXY` variable. Refusals from that proxy are an HTTP status and one line beginning `kosa8:`. Busybox `wget` (alpine's) got `400 Bad Request` for every HTTPS address before kosa8 0.9.36: it has no `CONNECT` and asks the proxy for `GET https://host/path` instead. From 0.9.36 the broker forwards that form, under the same allow-list, making the TLS connection and checking the certificate itself. ### The host is not on the list ``` HTTP/1.1 403 Forbidden kosa8: neverssl.com is not in this sandbox's egress allow-list; add it to the sandbox's egress policy (an exact name does not cover its subdomains) ``` **What it means.** The sandbox asked for a host its rule does not name. Listing `example.com` does not allow `api.example.com`; that needs its own entry or `*.example.com`. **What to do.** If the host is needed, add it to the kit's `egress_allow` or the sandbox's `--egress-allow`. If it is not, the rule is working. See [Network rules](https://docs.kosa8.com/guides/network/). ### The host is allowed, but not like that ``` kosa8: api.example.com is allowed, but not on port 8443; use an allowed port or add this one to the egress policy kosa8: DELETE is not an allowed method for api.example.com; allowed: GET, POST kosa8: example.internal resolves to a private, loopback or link-local address, which a name may not stand for; if that host is meant, list its IPv4 address in the egress policy ``` **What it means.** The name matched and something else did not: the port, the HTTP method, or where the name leads. The third message is a guard against a public name being pointed at something on your own network. **What to do.** Widen the rule only if the request is one you intended. For a host that really is on your private network, list its address rather than its name. ### A credential route refused the request ``` HTTP/1.1 403 Forbidden kosa8: this route needs the sandbox's placeholder credential HTTP/1.1 400 Bad Request kosa8: the placeholder credential also appears in …; it belongs only in the x-api-key header HTTP/1.1 405 Method Not Allowed kosa8: route anthropic does not allow DELETE; allowed: POST HTTP/1.1 403 Forbidden kosa8: route anthropic does not allow this path; allowed prefixes: /v1/messages HTTP/1.1 400 Bad Request kosa8: path has a . or .. segment; send the plain path the API documents HTTP/1.1 404 Not Found kosa8: no such route; the first path segment must be one of: anthropic ``` **What it means.** A route is the address the sandbox uses to spend a key it does not hold. The sandbox is given a stand-in value, the placeholder, and the broker swaps it for the real key on the way out. Each message is one of the route's checks: the placeholder is missing or wrong; the placeholder was also sent somewhere it should not be, such as the URL; the method is not one the route permits; the path is outside its prefixes; the path tries to climb with `..`; or the route's name is wrong. **What to do.** Usually nothing: the agent's tool is being held to the route the kit declared. If a legitimate call is refused, widen `methods:` or `paths:` for that credential in the kit. A wrong placeholder after you edited the environment by hand means you should let `kosa8 agent run` set it. ### The broker is busy, or the far end is down ``` kosa8: this sandbox already has N requests and tunnels open through the broker; close idle connections or retry shortly kosa8: api.example.com is allowed but could not be reached on port 443; check the host is up, then retry kosa8: the upstream is allowed but did not answer; check it is reachable from the host, then retry kosa8: this sandbox's egress broker has shut down; restart the sandbox's network to reach anything ``` **What it means.** Nothing was refused by policy. The first is a limit on simultaneous connections (HTTP 503). The next two mean your computer could not reach the destination (502). The last means the sandbox outlived its broker. **What to do.** Retry, and check the destination from your own terminal. A brokered sandbox that was stopped and started, restored or copied comes up with its network closed and no broker, because the keys lived only in the service's memory. Start a fresh one with `kosa8 agent run`. ### Going round the proxy ``` # a direct connection to 1.1.1.1, bypassing the proxy Connection refused # nslookup example.com server can't find example.com: REFUSED ``` **What it means.** A program ignored the proxy and tried to connect directly, or to look a name up itself. The network guard on your computer refused the packet. The exact wording belongs to the program; "connection refused" and DNS "REFUSED" are the signs. **What to do.** Make the program use the proxy; most honour `HTTPS_PROXY`. `kosa8 sandbox egress NAME` shows the counts, and the audit log has each attempt with `"reason":"not in egress policy deny"`. ### A sandbox the agent made has no network **What it means.** A sandbox created by an agent through the `sandbox_create` tool has no network unless the agent asks for one. Image pulls still work, because the service fetches images on your side. **What to do.** The agent names the hosts it needs in `egress_allow`, or asks for `egress: "any"`. Asking for both is refused: `egress_allow already limits the network to the hosts it lists; do not also set egress to "any"`. ## An AI client calling a tool Most of these come back to the agent as the tool's result, beginning `kosa8:`. The agent usually repeats them to you. ### The call needs a person ``` kosa8: deploys are reviewed — this call needs a person's approval and you cannot give it. Ask a person to review it with `kosa8 mcp asks` and approve it with `kosa8 mcp approve 3f2a9c0d1e4b5a67`, then make exactly the same call again ``` **What it means.** A rule answered "ask". The call is held, not denied. The text before the dash is the rule's reason. **What to do.** Run `kosa8 mcp asks`, read the arguments, then `kosa8 mcp approve ID` or `kosa8 mcp refuse ID`. Tell the agent to make the same call again. The approval lasts ten minutes and is used once. See [Rules for tool calls](https://docs.kosa8.com/guides/tool-rules/). ### Approving did not work ``` `kosa8 mcp approve` must be run by a person at a terminal no held call 3f2a9c0d1e4b5a67; list them with `kosa8 mcp asks` held call 3f2a9c0d1e4b5a67 expired; have the agent make the call again held call 3f2a9c0d1e4b5a67 was already approved ``` **What it means.** In order: the command was run through a pipe or by a script; the id is wrong, or the question lapsed and was cleared; the question waited more than an hour; it has been answered already. **What to do.** Run it yourself in a terminal, with the id from `kosa8 mcp asks`. If the agent says it is still waiting after you approved, it changed the arguments: a different call is a different question. ### A person said no ``` kosa8: a person refused this call (held call 3f2a9c0d1e4b5a67): deploys are reviewed ``` **What it means.** Somebody ran `kosa8 mcp refuse` for this exact call. The refusal stands until the original question's hour runs out. **What to do.** Nothing, unless it was a mistake. A different call is a new question. ### The arguments do not fit the tool ``` kosa8: the arguments do not match the schema echo/echo was approved with: … ``` **What it means.** The agent sent arguments the tool's pinned schema does not accept: a missing required field, a wrong type, or a field the tool never asked for. The server never saw the call. **What to do.** The agent should read the rest of the message and correct the call. If the server's schema really changed, see "withheld" below. ### The tool's answer was withheld ``` kosa8: echo/echo returned a result that does not match the output schema it declared, so it was withheld: … ``` **What it means.** The tool ran, and answered outside its own contract. The answer was not passed to the model. **What to do.** This is the tool server's fault, not the agent's. Report it to whoever maintains the server. Note that the tool did run. ### A rule denied the call ``` kosa8: do not echo secrets kosa8: tool "delete_repo" on server "github" is denied for scope "engineering" by rule deny_tools "delete_*" kosa8: tool "create_repo" is not in the allowlist for server "github" in scope "engineering" (allowed: list_*, get_*, search_*) ``` **What it means.** The first is an argument rule: the text is whatever its author wrote as the reason. The second and third are the tool lists. They are met at call time for kosa8's own tools, and when a client calls a third-party tool it was never offered. **What to do.** The reason is the instruction. If the rule is wrong, the policy has to be changed and signed again by whoever holds the key; nothing on your machine can override it. ### The rule fired because an argument could not be read ``` kosa8: …the rule's reason… [argument "/limit" could not be evaluated by "gt": it is a string, and this operator needs a number, so the rule was applied rather than risk letting the call through; send that argument in the type the tool's schema declares] ``` **What it means.** A rule compares a number and the agent sent text, or the other way round. kosa8 cannot tell whether the rule applies, so it applies it. **What to do.** The agent sends the argument in the right type: `5000`, not `"5000"`. ### The server or the scope is not approved ``` MCP server "randomvendor" is not approved for scope "engineering"; approved servers: docs, github — ask your admin to add it to the org policy scope "relase" is not defined in policy "example-corp" (defined: engineering, release); see `kosa8 mcp-policy scopes` no scope was given and policy "example-corp" sets no default_scope; pass --scope or have your admin set one ``` **What it means.** Under a signed MCP policy, a tool server nobody listed is denied. The second message usually means an agent identity was created with a mistyped `--scope`. The third means the gateway has no identity and the policy has no default. **What to do.** For the first, ask for the server to be added. For the second, revoke the identity and create it again with a scope from `kosa8 mcp-policy scopes`. For the third, give the gateway an identity. See [Agent identity](https://docs.kosa8.com/guides/agent-identity/). ### The policy itself is broken ``` the installed MCP policy failed verification: signature does not match MCP policy (tampered or wrong key); re-install a policy signed by your org with `kosa8 mcp-policy install` a trusted policy signer is pinned but no MCP policy is installed (was it deleted?); re-install it with `kosa8 mcp-policy install` the tool-call rule set is invalid, so every call is denied: … — fix the org policy and re-sign it ``` **What it means.** Every call is denied until the policy is whole again. An edited, deleted or malformed policy locks the door rather than opening it. **What to do.** Install the signed policy again. See [Company rules](https://docs.kosa8.com/guides/governance/). ### Sharing a folder was refused ``` host mounts are disabled for MCP agents: ask the user to restart `kosa8 mcp serve` with --allow-mount DIR (or DIR:rw) naming the directories a sandbox may share mount host path "/Users/you/other" is outside the directories this MCP server allows (--allow-mount) mount host path "/Users/you/code" may only be shared read-only (the user allowed it without :rw) ``` **What it means.** An agent may share a folder of yours into a sandbox it creates only if you named that folder when you started the gateway. **What to do.** Restart the gateway with `--allow-mount DIR`, or `DIR:rw` if the agent should be able to write there. Name the narrowest folder that does the job. ## Starting the gateway Printed by `kosa8 mcp serve` on standard error. If your AI client hides that output, run the command by hand in a terminal to read it. ### A tool was withheld ``` kosa8 mcp gateway: echo: tool "echo" withheld: its definition changed since it was pinned (sha256:…, now sha256:…) (review with `kosa8 mcp pins diff echo`, accept with `kosa8 mcp pins approve echo`) kosa8 mcp gateway: echo: tool "upload" withheld: it is new since this server's tools were pinned (review with `kosa8 mcp pins diff echo`, accept with `kosa8 mcp pins approve echo`) ``` **What it means.** The server now describes a tool differently from when you approved it, or offers one it did not offer before. Until a person accepts the change, the model is not shown that tool. **What to do.** Run `kosa8 mcp pins diff SERVER` and read the old and new descriptions. If the change is one you expected, such as after an upgrade, `kosa8 mcp pins approve SERVER` and restart the gateway. If not, stop using the server. See [Tool servers](https://docs.kosa8.com/guides/mcp-servers/). ### A tool was withheld by your organisation's pins ``` kosa8 mcp gateway: github: tool "create_gist" withheld: the org policy pins this server's tools and does not list this one (the org policy's pins decide this; ask your admin) kosa8 mcp gateway: github: tool "create_issue" withheld: its definition (sha256:…) is not the one the org policy pins (sha256:…); the server changed it, or the policy needs re-signing (the org policy's pins decide this; ask your admin) ``` **What it means.** The signed policy fixes this server's tool definitions. Nothing can be approved locally. **What to do.** Send your administrator the output of `kosa8 mcp pins show SERVER` from a machine where the new version was reviewed. ### A tool's schema cannot be checked ``` kosa8 mcp gateway: vendor: tool "search" withheld: its input schema cannot be enforced: … ``` **What it means.** The schema refers to something off your machine, recurses without end, or nests too deeply to check safely. A tool whose arguments cannot be checked is not offered. **What to do.** Report it to the server's maintainer. There is no override. ### A server exposed nothing, or did not start ``` kosa8 mcp gateway: github: exposed none of 26 tools (0 excluded by the allowlist, 0 collided with existing tool names, 26 denied by org MCP policy, 0 withheld because their definitions are not the pinned ones or cannot be checked) (skipped) kosa8 mcp gateway: github: tool "github_status" collides with an existing tool, skipping kosa8 mcp gateway: github: mcp handshake: … (skipped) ``` **What it means.** The first line counts which filter removed each tool, because each has a different fix: your own `--allow-tool` list, a clash of names, the organisation's policy, or pins. The last is a server that crashed or never spoke MCP. A skipped server never stops the gateway or kosa8's own tools. **What to do.** Follow the largest number. For a handshake failure, check the command with `kosa8 mcp ls`, and whether the server needs a host its network rule does not allow (a server started with `npx` needs its package registry). ### The gateway must identify itself ``` the org policy requires agents to identify themselves: mint a token with `kosa8 agent token create NAME` and start this gateway with KOSA8_AGENT_TOKEN set to it ``` **What it means.** Your organisation's policy sets `require_agent_identity`, and this gateway was started with no token. **What to do.** Create an identity and put its token in the gateway's environment. See [Agent identity](https://docs.kosa8.com/guides/agent-identity/). ### The token was not accepted ``` the daemon did not accept the agent token: agent token is not recognised; mint one with `kosa8 agent token create NAME` the daemon did not accept the agent token: agent reviewer (agt_…) was revoked on 2026-…; mint a new token with `kosa8 agent token create` the daemon did not accept the agent token: agent reviewer (agt_…) expired on 2026-…; mint a new token with `kosa8 agent token create` ``` **What it means.** The token is mistyped or cut short, belongs to another machine's registry, was revoked, or has passed its lifetime. A bad token is refused outright; the gateway is not allowed to carry on as an anonymous agent. **What to do.** `kosa8 agent token ls` shows the state of each identity. Create a new token and restart the gateway. Tokens are shown once, so a lost one is replaced, not recovered. ### The gateway will not listen there ``` refusing to serve MCP on 0.0.0.0:7433: it is not a loopback address, and anyone who can reach it can run containers on this machine. Bind 127.0.0.1 (e.g. --http 127.0.0.1:7433), or pass --insecure-listen if it is fronted by something you trust unauthorized: send Authorization: Bearer (printed by kosa8 mcp serve, or KOSA8_MCP_TOKEN) ``` **What it means.** The first is the gateway declining to listen on an address other machines can reach. An address with no host, such as `:7433`, counts as every interface. The second is an HTTP client that sent no token or the wrong one. **What to do.** Bind `127.0.0.1`. Give the client the token printed at start, the value of `KOSA8_MCP_TOKEN`, or, for an identified gateway with no `KOSA8_MCP_TOKEN`, the agent token. ### The service is not running ``` kosa8 daemon unreachable (start it with `kosa8 daemon start`) ``` **What it means.** The gateway is only a door. The work is done by the background service, and it is not answering. **What to do.** `kosa8 daemon start`, then `kosa8 daemon status`. If it will not stay up, see [When something is wrong](https://docs.kosa8.com/guides/self-repair/). ### A decision could not be written down ``` kosa8 mcp gateway: echo/echo: decision not anchored in the audit log: … ``` **What it means.** The gateway decided a call and could not record the decision in the audit log, usually because the service was unreachable at that moment. The decision itself stood. **What to do.** Check the service. A line is still written to the flat decision file that `kosa8 mcp-policy events` reads, without an audit sequence number. ## Company policy Refusals that come from the organisation's signed policy. None of these can be changed from the machine that shows them. What each field does is in [Policy reference](https://docs.kosa8.com/guides/policy-reference/). ### The registry is not allowed ``` policy "example-corp" forbids registry "quay.io" (allowed: docker.io, ghcr.io) ``` **What it means.** `allowed_registries` lists where images may come from, and this image is from somewhere else. The check applies wherever an image is fetched: a pull, a run that needs a missing image, a build's base image, and the Docker-compatible API. **What to do.** Use an image from a listed registry, or ask for the registry to be added. ### The tool server's image is not allowed ``` policy "example-corp" forbids MCP image "alpine" (allowed: node:22-alpine) ``` **What it means.** `allowed_mcp` lists the images that may run as tool servers. It is checked by `kosa8 mcp add` and again when the gateway starts the server. The comparison is on the whole image name. **What to do.** Register the server with a listed image, written exactly as the policy writes it. ### Containers must run in a sandbox ``` policy "example-corp" requires containers to run in a sandbox, and this would run on the shared engine; use `kosa8 sandbox create` and `kosa8 sandbox run` ``` **What it means.** `require_sandbox` is set, so `kosa8 run`, the `container_run` tool and the Docker-compatible API refuse to use the shared engine. **What to do.** Create a sandbox and run the container inside it, as the message says. An agent uses `sandbox_create` and `sandbox_run`. ### Agents must identify themselves ``` the org policy requires agents to identify themselves, and this request carries no agent token ``` **What it means.** The service's own version of the gateway message above, answered with HTTP 401 to any request that says it is from an agent and carries no token. It is recorded in the audit log as `agent.auth.refused`. **What to do.** Give the agent's gateway a token. ### Creating an identity was refused ``` an agent cannot mint agent identities an agent named "reviewer" already has a live token (agt_…); revoke it first with `kosa8 agent token revoke reviewer` a token may live at most 365 days; rotate longer-lived identities Governance needs a subscription ``` **What it means.** In order: an agent tried to create an identity, which only a person may do; the name is in use by a live identity; the lifetime asked for is too long; creating identities is part of the Governance subscription. Listing and revoking never need one. **What to do.** Run the command yourself; revoke the old token first; ask for a shorter `--ttl`; or get a plan that carries Governance with `kosa8 upgrade team` ($30 off your first payment). ### The organisation policy does not verify ``` org policy present but failed verification, refusing to run: … org policy present but failed verification, so an unidentified gateway cannot be started: … no trusted policy signer is pinned, so any key would be accepted here — pin your org's public key first with `kosa8 policy trust ` ``` **What it means.** The policy file on this machine was edited, or is signed by a key this machine does not trust. kosa8 treats every requirement in it as set and refuses. The last message is different: it appears when installing a policy before pinning who may sign one. **What to do.** Install the signed policy again with `kosa8 policy install`. For the last message, pin the key first. Both are covered in [Company rules](https://docs.kosa8.com/guides/governance/). #### What this page does not cover - Messages written by the tools themselves. An agent's own CLI, a tool server and `curl` each word their failures their own way; only the lines beginning `kosa8:` are kosa8's. - Requests that were allowed. A tunnel to an allowed host is opaque: kosa8 records the host and port, and cannot say what was sent through it. - Why a model chose to make a call. The audit log records what was asked and what was decided, with digests of the arguments, not the arguments. ## Where to go next [`Securing an agent` The layers these messages come from, in one place.](https://docs.kosa8.com/guides/secure-agents/) [`Network rules` What a sandbox can reach, and how to change it.](https://docs.kosa8.com/guides/network/) [`The audit log` Reading entries and proving nobody edited them.](https://docs.kosa8.com/guides/audit/) [`When something is wrong` Problems with kosa8 itself rather than with a refusal.](https://docs.kosa8.com/guides/self-repair/) --- Source: https://docs.kosa8.com/guides/troubleshooting/ # kosa8 Cloud MCP server kosa8 Cloud has a remote MCP server at `https://api.kosa8.com/mcp`. Connect your AI agent to it with your Cloud API key and it can create sealed cloud sandboxes, run commands in them, put them to sleep and delete them, with nothing installed on your machine. You need a kosa8 Cloud API key ([kosa8 Cloud](https://docs.kosa8.com/guides/cloud/) shows how to get one). Give the agent its own key with the `sandboxes:write` scope, so you can revoke it without touching your other keys. Then put it in your shell: ``` $ export KOSA8_API_KEY=k8c_live_… ``` ## Claude Code ``` $ claude mcp add --transport http --scope user kosa8-cloud https://api.kosa8.com/mcp --header "Authorization: Bearer $KOSA8_API_KEY" ``` The shell fills in the key, so Claude Code stores the key itself. `--scope user` makes it available in every project; leave it out to add it to this project only. Check it with `claude mcp list`, or `/mcp` inside Claude Code. ## Cursor Add this to `~/.cursor/mcp.json` (every project) or `.cursor/mcp.json` in one project. Cursor fills in `${env:KOSA8_API_KEY}` from its environment, so the key is not written into the file: ``` { "mcpServers": { "kosa8-cloud": { "url": "https://api.kosa8.com/mcp", "headers": { "Authorization": "Bearer ${env:KOSA8_API_KEY}" } } } } ``` Cursor has to see the variable: on macOS an app opened from the Dock does not get your shell's variables, so start Cursor from a terminal where it is set, or put the key itself in the file with `kosa8 cloud mcp --install cursor` (below). ## Codex Add this to `~/.codex/config.toml`. Codex sends the key it reads from `KOSA8_API_KEY` as the bearer token: ``` [mcp_servers.kosa8-cloud] url = "https://api.kosa8.com/mcp" bearer_token_env_var = "KOSA8_API_KEY" ``` ## Any other MCP client The server speaks the MCP **Streamable HTTP** transport (protocol versions 2025-03-26, 2025-06-18 and 2025-11-25). Point the client at: | URL | `https://api.kosa8.com/mcp` | |---|---| | Header | `Authorization: Bearer k8c_live_…` | A client that only starts local (stdio) servers, such as Claude Desktop's config file, can reach it through the `mcp-remote` bridge (needs Node.js): ``` { "mcpServers": { "kosa8-cloud": { "command": "npx", "args": ["-y", "mcp-remote", "https://api.kosa8.com/mcp", "--header", "Authorization:${KOSA8_AUTH_HEADER}"], "env": { "KOSA8_AUTH_HEADER": "Bearer k8c_live_…" } } } } ``` ## Or let the kosa8 CLI do it Signed in with `kosa8 cloud login`, the CLI prints every snippet above for your key, and writes them only when you ask: ``` $ kosa8 cloud mcp # print the config for each client; writes nothing $ kosa8 cloud mcp cursor --show-key # Cursor's, with the key itself in it $ kosa8 cloud mcp --install claude-code # or cursor, codex; none named: every one found $ kosa8 cloud mcp --uninstall cursor ``` `--install` keeps the client's other servers and saves the old file as `.bak`. Cursor's file gets the key and is made readable only by you; Codex's gets only the variable name. The server is named `kosa8-cloud`, so it sits beside the local `kosa8` server from `kosa8 mcp install` ([Connect Claude, Cursor or Codex](https://docs.kosa8.com/guides/connect-agents/)), which runs sandboxes on your own machine. ## Tools | Tool | Does | Scope | |---|---|---| | `sandbox_create` | Start a sandbox: `template`, `region`, `sleep_after_s`, `ttl_s`, all optional. | `sandboxes:write` | | `sandbox_list` | The organization's sandboxes, their state and cost so far. | `sandboxes:read` | | `sandbox_exec` | Run `argv` (exact arguments) or `command` (a shell line, run with `sh -c`), with optional `env`. Returns the exit code, stdout and stderr. A sleeping sandbox wakes first; `/workspace` persists between commands. | `sandboxes:write` | | `sandbox_sleep` | Snapshot it and free its RAM; it then costs only snapshot storage. | `sandboxes:write` | | `sandbox_wake` | Restore a sleeping sandbox now. | `sandboxes:write` | | `sandbox_delete` | Delete it and everything in it. | `sandboxes:write` | | `usage_get` | This month's bill so far, the spending cap, and whether new work is paused. | `sandboxes:read` | | `templates_list` | The templates you can create now. | `sandboxes:read` | The agent sees only the tools its key's scopes allow: a `sandboxes:read` key gets the three read tools. Output longer than 100 KB per stream is cut, with a note saying how much. ## Same rules as the API Each tool runs the same code as its REST call, so everything that applies to the API applies here: the key's scopes, payment, your spending cap, the sandbox quota, and the audit log. Each call appears in **Audit** with `via: mcp`; an exec records its arguments, the names of its environment variables and its exit code, never its output or variable values. When kosa8 Cloud refuses something, the agent gets a tool error it can read, not a broken connection. A **402** (`payment_required` or `spending_cap_reached`) includes a sentence meant for you and the page that fixes it, for example: ``` kosa8 Cloud refused this (HTTP 402 payment_required): add a payment method to start running work; … Tell the person: kosa8 Cloud needs a payment method or credit to run more work. Ask the person to open https://app.kosa8.com/#usage upgrade_url: https://app.kosa8.com/#usage ``` The server tells agents to pass that on and stop, rather than retry. ## Details - Stateless: each `POST /mcp` carries one JSON-RPC 2.0 message (or a batch) and is answered with `application/json`. There is no session id and no event stream, so `GET` and `DELETE` return 405. - Only a bearer API key is accepted, never the dashboard's sign-in cookie. A missing or revoked key is HTTP 401. Try it by hand: ``` curl -s https://api.kosa8.com/mcp -H "Authorization: Bearer $KOSA8_API_KEY" \ -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` --- Source: https://docs.kosa8.com/guides/cloud-mcp/ # OpenAPI and llms.txt The kosa8 Cloud API describes itself. `https://api.kosa8.com/openapi.json` is an OpenAPI 3.1 document for code generators and API tools, and `https://api.kosa8.com/llms.txt` is a short plain-text brief for AI agents. ## openapi.json Every `/v1` route the API serves is in it, with its key scope (`x-kosa8-scope`), parameters, request bodies and error answers. The operator-only `/v1/admin` routes are left out. A test in the API's own suite fails if a route is added without being described, so the document does not drift from what the server does. ``` $ curl -s https://api.kosa8.com/openapi.json | jq '.paths | keys' ``` It is public and needs no key. Use it to: - **Generate a client** in a language kosa8 has no SDK for, for example with `openapi-generator-cli generate -i https://api.kosa8.com/openapi.json -g rust -o kosa8-rs`. The [SDKs](https://docs.kosa8.com/guides/sdks/) remain the easier path in Python, JavaScript, Go, Ruby and Java. - **Import into Postman, Insomnia or Bruno**: import from URL. Set the bearer token to your `k8c_live_…` key. - **Give an agent typed tools**: frameworks that turn an OpenAPI document into tools (for example LangChain's OpenAPI toolkit) can load it directly. Point them at the `Sandboxes` tag; for most agents the [MCP server](https://docs.kosa8.com/guides/cloud-mcp/) is simpler. ## Auth and errors, as the document states them - Security scheme `apiKey`: HTTP bearer, the key from [API keys](https://app.kosa8.com/app#keys) in the dashboard. - Every error is `{"error": "", "message": "…"}`. A **402** (`payment_required`, `spending_cap_reached`) also carries `upgrade_url` and `agent_message`, the sentence an AI agent should pass to its person. - Webhook deliveries are described under `webhooks`; see [Webhook recipes](https://docs.kosa8.com/guides/cloud-webhooks/) for verifying them. ## llms.txt `https://api.kosa8.com/llms.txt` follows the [llms.txt](https://llmstxt.org/) convention: in a page of markdown, how to authenticate, how to connect over MCP, the main calls with their bodies, a curl example, and links to the full reference. Paste its address into an agent's context, or let a crawler find it, and the agent has what it needs to drive the API. ``` $ curl -s https://api.kosa8.com/llms.txt ``` The docs site has its own [llms.txt](https://docs.kosa8.com/llms.txt) covering every guide and command. --- Source: https://docs.kosa8.com/guides/cloud-openapi/ # GitHub Action The `kosa8/sandbox` action runs a command in a sealed kosa8 Cloud sandbox from a workflow, prints its output and deletes the sandbox. Use it for untrusted code, an AI agent's changes, or anything you would rather not run on the CI runner itself. ### Add your key as a secret Make a kosa8 Cloud API key with the `sandboxes:write` scope at [app.kosa8.com](https://app.kosa8.com/app#keys). In the repository, open **Settings → Secrets and variables → Actions** and add it as `KOSA8_API_KEY`. ### Use the action ``` jobs: sandbox: runs-on: ubuntu-latest steps: - id: box uses: kosa8/sandbox@v1 with: api-key: ${{ secrets.KOSA8_API_KEY }} template: python command: python3 -c "import platform; print(platform.platform())" - run: echo "exit ${{ steps.box.outputs.exit-code }} in ${{ steps.box.outputs.sandbox-id }}" ``` ## Inputs and outputs | Input | Default | What it does | |---|---|---| | `api-key` | required | A kosa8 Cloud API key. Masked in the log. | | `command` | required | A shell line, run as `sh -c` in the sandbox. Several lines are fine. | | `template` | org default | `base`, `python`, `node`, or any template your organization has. | | `timeout` | `600` | Seconds to wait for the command. The sandbox is also created with a lifetime of `timeout` + 300 s, so a cancelled job leaves nothing running. | | `keep` | `false` | `true` keeps the sandbox (no lifetime) for later steps or for looking at afterwards; delete it yourself. | | `fail-on-error` | `true` | Fail the step when the command exits non-zero. | | `api-url` | `https://api.kosa8.com` | The API to call. | | Output | Is | |---|---| | `exit-code` | The command's exit code. | | `sandbox-id` | The sandbox's id, `sbx_…`. | ## How it behaves - stdout and stderr are printed, in collapsible groups, when the command finishes. The API returns a command's output at the end today, so a long command shows nothing until it is done. - The sandbox is deleted when the step ends, whether the command passed, failed or the step errored. If that delete fails, the step warns, and the sandbox's lifetime removes it. - A refusal is explained in the log. For `402 payment_required` or `spending_cap_reached` the log says what to do and links [app.kosa8.com/#usage](https://app.kosa8.com/#usage). - It is plain bash with `curl` and `jq`, both on GitHub's hosted runners: no Node.js build and nothing downloaded at run time. - It costs what any kosa8 Cloud sandbox costs: per second while it runs ([kosa8 Cloud](https://docs.kosa8.com/guides/cloud/) has the rates). **Keep untrusted text out of `command`.** GitHub pastes `${{ … }}` expressions into the line before it runs, so a branch name or PR title put there becomes shell code in the sandbox. Values such as `github.repository` and `github.sha` are safe. ## A full workflow This runs a public repository's tests inside a sandbox on every push: ``` name: tests in a kosa8 sandbox on: push: permissions: contents: read jobs: test: runs-on: ubuntu-latest steps: - id: box uses: kosa8/sandbox@v1 with: api-key: ${{ secrets.KOSA8_API_KEY }} template: python timeout: "900" command: | set -e cd /workspace python3 -c "import io, tarfile, urllib.request; tarfile.open(fileobj=io.BytesIO(urllib.request.urlopen('https://codeload.github.com/${{ github.repository }}/tar.gz/${{ github.sha }}').read())).extractall('src')" cd src/* python3 -m pip install -q -r requirements.txt pytest python3 -m pytest -q ``` Plain HTTP calls work from any CI system too; the action is the same three calls (`POST /v1/sandboxes`, `POST /v1/sandboxes/{id}/exec`, `DELETE /v1/sandboxes/{id}`) shown in [kosa8 Cloud](https://docs.kosa8.com/guides/cloud/). --- Source: https://docs.kosa8.com/guides/cloud-github-action/ # Webhook recipes kosa8 Cloud can POST an event to your server when a sandbox fails, your spending cap is reached, and more. This page has receivers to copy in Node, Python and Go: each checks the signature and forwards `sandbox.error` and `spending_cap.reached` to a Slack channel. ## Add an endpoint In the dashboard, or with an `admin` key. The URL must be public `https`. The `whsec_…` signing secret is in the answer, once: store it. ``` curl -s -X POST https://api.kosa8.com/v1/webhooks \ -H "Authorization: Bearer $KOSA8_API_KEY" -H 'Content-Type: application/json' \ -d '{"url":"https://hooks.example.com/kosa8","events":["sandbox.error","spending_cap.reached"]}' # {"webhook":{"id":"wh_…","url":"https://hooks.example.com/kosa8",…,"secret":"whsec_…"},…} # later: send a webhook.test event, and see recent deliveries curl -s -X POST https://api.kosa8.com/v1/webhooks/wh_…/test -H "Authorization: Bearer $KOSA8_API_KEY" curl -s https://api.kosa8.com/v1/webhooks/wh_…/deliveries -H "Authorization: Bearer $KOSA8_API_KEY" ``` Leave out `events` to get all of them: `sandbox.created`, `sandbox.deleted`, `sandbox.slept`, `sandbox.woke`, `sandbox.error`, `function.deployed`, `function.invocation.failed` and `spending_cap.reached` (sent once a month). ## What arrives A `POST` with a JSON body and three headers: ``` Kosa8-Signature: t=1794400000,v1=5f0c…e81a Kosa8-Event: sandbox.error Kosa8-Delivery: whd_… {"id":"evt_…","type":"sandbox.error","created_at":1794400000000,"org_id":"org_…", "data":{"id":"sbx_…","error":"…","during":"create"}} ``` | Event | `data` | |---|---| | `sandbox.error` | `id`, `error`, `during` (what was being done, e.g. `create`) | | `spending_cap.reached` | `cost_usd`, `spending_cap_usd`, `month` (`YYYY-MM`) | `v1` is the hex HMAC-SHA256 of `"."`, keyed with the whole secret, `whsec_` included. To check it: 1. Read the raw body bytes before parsing them; re-serialized JSON will not match. 2. Compute the HMAC and compare it to `v1` in constant time. 3. Refuse a `t` more than five minutes from now, so a captured request cannot be replayed later. Answer any `2xx` to acknowledge. Anything else, a timeout over 10 seconds, or a redirect is retried after 1 min, 5 min, 30 min, 2 h and 8 h, then marked failed. A retry carries the same `Kosa8-Delivery`, so use it to skip one you have already handled. ## Slack Each receiver below posts to a Slack [incoming webhook](https://api.slack.com/messaging/webhooks). Create one for the channel (Slack: **Apps → Incoming Webhooks → Add to Slack**) and run the receiver with both secrets in its environment: ``` $ export KOSA8_WEBHOOK_SECRET=whsec_… $ export SLACK_WEBHOOK_URL=https://hooks.slack.com/services/… ``` Other events are acknowledged and not forwarded. If Slack is down, the receiver answers 502 and kosa8 Cloud tries again later. ### Node.js Node 18 or later, no packages: ``` // kosa8 webhook receiver: verify Kosa8-Signature, forward two events to Slack. // KOSA8_WEBHOOK_SECRET=whsec_… SLACK_WEBHOOK_URL=https://hooks.slack.com/services/… node server.mjs import crypto from "node:crypto"; import http from "node:http"; const SECRET = process.env.KOSA8_WEBHOOK_SECRET; const SLACK = process.env.SLACK_WEBHOOK_URL; // verifyKosa8 checks the HMAC over "." and refuses a t more than // toleranceS seconds away (a replay). export function verifyKosa8(rawBody, header, secret, toleranceS = 300) { const parts = {}; for (const kv of String(header || "").split(",")) { const i = kv.indexOf("="); if (i > 0) parts[kv.slice(0, i).trim()] = kv.slice(i + 1).trim(); } const t = Number(parts.t); if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceS) return false; const want = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest(); const got = Buffer.from(parts.v1 || "", "hex"); return got.length === want.length && crypto.timingSafeEqual(got, want); } function slackText(evt) { const d = evt.data || {}; if (evt.type === "sandbox.error") { return `:warning: kosa8 sandbox ${d.id} failed during ${d.during}: ${d.error}`; } if (evt.type === "spending_cap.reached") { return `:money_with_wings: kosa8 Cloud reached its spending cap for ${d.month}: ` + `$${Number(d.cost_usd).toFixed(2)} of $${d.spending_cap_usd}. New work is refused until the cap is raised.`; } return null; // other events: acknowledged, not forwarded } const server = http.createServer((req, res) => { const chunks = []; req.on("data", (c) => chunks.push(c)); req.on("end", async () => { const raw = Buffer.concat(chunks).toString("utf8"); // the exact bytes signed if (req.method !== "POST" || !verifyKosa8(raw, req.headers["kosa8-signature"], SECRET)) { res.writeHead(401).end(); return; } const evt = JSON.parse(raw); const text = slackText(evt); if (text) { const r = await fetch(SLACK, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }) }); if (!r.ok) { res.writeHead(502).end(); return; } // kosa8 retries later } res.writeHead(200).end("ok"); }); }); server.listen(Number(process.env.PORT || 8080)); ``` ### Python Python 3.8 or later, standard library only: ``` # kosa8 webhook receiver: verify Kosa8-Signature, forward two events to Slack. # KOSA8_WEBHOOK_SECRET=whsec_… SLACK_WEBHOOK_URL=https://hooks.slack.com/services/… python3 server.py import hashlib import hmac import json import os import time import urllib.request from http.server import BaseHTTPRequestHandler, HTTPServer SECRET = os.environ["KOSA8_WEBHOOK_SECRET"] SLACK = os.environ["SLACK_WEBHOOK_URL"] def verify_kosa8(raw: bytes, header: str, secret: str, tolerance_s: int = 300) -> bool: """Check the HMAC over b"." + raw body; refuse a t more than tolerance_s away.""" parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p) ts = parts.get("t", "") if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance_s: return False want = hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest() return hmac.compare_digest(want, parts.get("v1", "")) def slack_text(evt: dict): d = evt.get("data") or {} if evt.get("type") == "sandbox.error": return f":warning: kosa8 sandbox {d.get('id')} failed during {d.get('during')}: {d.get('error')}" if evt.get("type") == "spending_cap.reached": return (f":money_with_wings: kosa8 Cloud reached its spending cap for {d.get('month')}: " f"${float(d.get('cost_usd', 0)):.2f} of ${d.get('spending_cap_usd')}. New work is refused until the cap is raised.") return None # other events: acknowledged, not forwarded class Handler(BaseHTTPRequestHandler): def do_POST(self): raw = self.rfile.read(int(self.headers.get("Content-Length", 0))) # the exact bytes signed if not verify_kosa8(raw, self.headers.get("Kosa8-Signature"), SECRET): self.send_response(401) self.end_headers() return text = slack_text(json.loads(raw)) if text: req = urllib.request.Request(SLACK, data=json.dumps({"text": text}).encode(), headers={"Content-Type": "application/json"}) try: urllib.request.urlopen(req, timeout=10).read() except OSError: self.send_response(502) # kosa8 retries later self.end_headers() return self.send_response(200) self.end_headers() self.wfile.write(b"ok") HTTPServer(("", int(os.environ.get("PORT", "8080"))), Handler).serve_forever() ``` ### Go ``` // kosa8 webhook receiver: verify Kosa8-Signature, forward two events to Slack. // KOSA8_WEBHOOK_SECRET=whsec_… SLACK_WEBHOOK_URL=https://hooks.slack.com/services/… go run . package main import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "strconv" "strings" "time" ) // VerifyKosa8 checks the HMAC over "." + raw body and refuses a t more // than tolerance away (a replay). func VerifyKosa8(raw []byte, header, secret string, tolerance time.Duration) bool { var ts, sig string for _, p := range strings.Split(header, ",") { k, v, ok := strings.Cut(strings.TrimSpace(p), "=") if !ok { continue } switch k { case "t": ts = v case "v1": sig = v } } t, err := strconv.ParseInt(ts, 10, 64) if err != nil { return false } if d := time.Since(time.Unix(t, 0)); d > tolerance || d < -tolerance { return false } got, err := hex.DecodeString(sig) if err != nil { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(ts + ".")) mac.Write(raw) return hmac.Equal(got, mac.Sum(nil)) } type event struct { Type string `json:"type"` Data map[string]any `json:"data"` } func slackText(e event) string { switch e.Type { case "sandbox.error": return fmt.Sprintf(":warning: kosa8 sandbox %v failed during %v: %v", e.Data["id"], e.Data["during"], e.Data["error"]) case "spending_cap.reached": cost, _ := e.Data["cost_usd"].(float64) return fmt.Sprintf(":money_with_wings: kosa8 Cloud reached its spending cap for %v: $%.2f of $%v. New work is refused until the cap is raised.", e.Data["month"], cost, e.Data["spending_cap_usd"]) } return "" // other events: acknowledged, not forwarded } func main() { secret, slack := os.Getenv("KOSA8_WEBHOOK_SECRET"), os.Getenv("SLACK_WEBHOOK_URL") http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // the exact bytes signed if err != nil || r.Method != http.MethodPost || !VerifyKosa8(raw, r.Header.Get("Kosa8-Signature"), secret, 5*time.Minute) { http.Error(w, "bad signature", http.StatusUnauthorized) return } var e event if err := json.Unmarshal(raw, &e); err != nil { http.Error(w, "bad body", http.StatusBadRequest) return } if text := slackText(e); text != "" { body, _ := json.Marshal(map[string]string{"text": text}) resp, err := http.Post(slack, "application/json", bytes.NewReader(body)) if err != nil || resp.StatusCode >= 300 { http.Error(w, "slack failed", http.StatusBadGateway) // kosa8 retries later return } resp.Body.Close() } fmt.Fprint(w, "ok") }) port := os.Getenv("PORT") if port == "" { port = "8080" } http.ListenAndServe(":"+port, nil) } ``` Already using a framework? Keep the verify function and call it with the raw body: in Express use `express.raw({ type: "application/json" })` on that route, in Flask `request.get_data()`, in FastAPI `await request.body()`. ## Test it Send yourself a signed event without waiting for a real one: ``` $ curl -s -X POST https://api.kosa8.com/v1/webhooks/wh_…/test -H "Authorization: Bearer $KOSA8_API_KEY" ``` That sends `webhook.test`, which the receivers above acknowledge without posting to Slack. To see the Slack message, set a spending cap just under this month's spend in **Settings**: the next create is refused and `spending_cap.reached` is sent. --- Source: https://docs.kosa8.com/guides/cloud-webhooks/ # SDKs in five languages kosa8 has an official SDK for Python, TypeScript, Go, Ruby and Java. Each one lets your program create sealed machines, run code in them, save their state and roll it back. All five do the same things with the same names, so you can switch languages without relearning anything. ## In plain words When an AI agent writes code and runs it, that code can do anything your user account can do. It can delete files, read your SSH keys, or send your API keys to someone else. kosa8 stops that by running the code in a **sandbox**: a small, separate computer that boots in seconds on your own machine. Code in the sandbox cannot see your files unless you share them, and cannot reach the internet unless you allow it. The **kosa8 app** (a background program called the daemon) runs those sandboxes. An **SDK** is a library your own program uses to talk to it. With a few lines of code you can: - **Create a sandbox**, choosing how much CPU, memory and network it gets. - **Run commands** in it and read their output, live or when they finish. - **Take a checkpoint**, a save point, before a risky step, and **rewind** to it if the step goes wrong. Running programs come back exactly where they were. - **Fork** a sandbox into several copies and try different ideas at once, then keep the one that worked (**explore**). - **Delete** it when you are done, optionally overwriting its disks. Everything happens on your machine. Nothing is uploaded, and there is no account to create. ## Install First install kosa8 itself ([Install](https://docs.kosa8.com/guides/install/)) and make sure it is running (`kosa8 daemon start`). Then add the SDK for your language: | Language | Package | Install | Needs | Start here | |---|---|---|---|---| | Python | `kosa8` on PyPI | `pip install kosa8` | Python 3.9+ | [Python quickstart](https://docs.kosa8.com/guides/py-quickstart/) | | TypeScript / JavaScript | `@kosa8/sdk` on npm | `npm install @kosa8/sdk` | Node 18+ | [JS quickstart](https://docs.kosa8.com/guides/js-quickstart/) | | Go | `github.com/kosa8ai/kosa8-go` | `go get github.com/kosa8ai/kosa8-go@v0.1.2` | Go 1.21+ | [Go quickstart](https://docs.kosa8.com/guides/go-quickstart/) | | Ruby | `kosa8` on RubyGems | `gem install kosa8` | Ruby 2.6+ | [Ruby quickstart](https://docs.kosa8.com/guides/rb-quickstart/) | | Java | `com.kosa8:kosa8` on Maven Central | Maven or Gradle dependency, version `0.1.2` | Java 17+ | [Java quickstart](https://docs.kosa8.com/guides/java-quickstart/) | None of the SDKs has a runtime dependency. Each uses only its language's standard library, so adding one brings nothing else into your build. All are MIT licensed, and the source is on GitHub: [kosa8-py](https://github.com/kosa8ai/kosa8-py), [kosa8-js](https://github.com/kosa8ai/kosa8-js), [kosa8-go](https://github.com/kosa8ai/kosa8-go), [kosa8-rb](https://github.com/kosa8ai/kosa8-rb), [kosa8-java](https://github.com/kosa8ai/kosa8-java). ## The same program in every language Create a sandbox with no network, run a command, delete it. ``` # Python import kosa8 k = kosa8.Kosa8Client() k.create_sandbox(name="demo", egress="deny") k.pull_into_sandbox("demo", "alpine:latest") print(k.run("demo", "alpine:latest", ["echo", "hello"]).stdout) k.delete_sandbox("demo") ``` ``` // TypeScript import { Kosa8Client } from "@kosa8/sdk"; const k = new Kosa8Client(); await k.createSandbox({ name: "demo", egress: "deny" }); await k.pullIntoSandbox("demo", "alpine:latest"); console.log((await k.run("demo", "alpine:latest", ["echo", "hello"])).stdout); await k.deleteSandbox("demo"); ``` ``` // Go k, _ := kosa8.New(kosa8.Options{}) k.CreateSandbox(ctx, kosa8.CreateOptions{Name: "demo", Egress: "deny"}) k.PullIntoSandbox(ctx, "demo", "alpine:latest") res, _ := k.Run(ctx, "demo", "alpine:latest", []string{"echo", "hello"}, nil) fmt.Print(res.Stdout()) k.DeleteSandbox(ctx, "demo", kosa8.DeleteOptions{}) ``` ``` # Ruby require "kosa8" k = Kosa8::Client.new k.create_sandbox(name: "demo", egress: "deny") k.pull_into_sandbox("demo", "alpine:latest") puts k.run("demo", "alpine:latest", ["echo", "hello"]).stdout k.delete_sandbox("demo") ``` ``` // Java Kosa8Client k = Kosa8Client.builder().build(); k.createSandbox(new Options.Create().name("demo").egress("deny")); k.pullIntoSandbox("demo", "alpine:latest"); System.out.print(k.run("demo", "alpine:latest", List.of("echo", "hello")).stdout()); k.deleteSandbox("demo"); ``` The names follow each language's own style (`create_sandbox` in Python and Ruby, `createSandbox` in TypeScript and Java, `CreateSandbox` in Go), but the operations, their options and what they return are the same. ## What every SDK covers | Area | Operations | |---|---| | Daemon | health, version, pull an image to the host | | Sandboxes | list, get, create, start, stop, delete (with disk wipe), pull an image into one, list its images and containers, read its network policy and decisions | | Running code | run to completion, start a detached container, exec in it, interactive containers with stdin, attach to output, raw output frames | | Snapshots | snapshot (optionally encrypted), list, restore under a new name, delete (with wipe or force) | | Fork and explore | N live copies of a running sandbox; best-of-N that runs one idea per copy and keeps the fastest success | | Checkpoints | checkpoint, list, rewind to the latest or to a named one; shared with `kosa8 checkpoint` and `kosa8 rewind` | | Connections | local socket by default; remote workers over HTTPS with a bearer token; actor and agent-identity headers; signed approvals per call | | Errors | typed errors for every daemon answer (bad request, auth, policy, approval required, not found, conflict, unsupported), plus connection, stream and timeout errors, each with the daemon's own remediation text | ## A few words you will see DaemonThe kosa8 background program, `kosa8d`. The SDKs send it requests over a local socket file, `~/.kosa8/run/kosa8.sock`. SandboxA small virtual machine with its own disk, memory and network rules. ImageA container image such as `python:3.12-alpine`. Code runs in a container made from an image, inside the sandbox. EgressWhat the sandbox may reach on the network: `deny` (nothing), `any`, `fakenet` (a fake internet for malware analysis) or `allow:pypi.org,github.com` (only those hosts). SnapshotA saved copy of a running sandbox: memory, running programs and disks. CheckpointA snapshot on a sandbox's own undo stack, named `-ckpt-N`. Ten are kept per sandbox. ForkSeveral live copies of one sandbox, made from a single snapshot. ApprovalA signature from a person, required by your organisation's policy before some actions. You pass it with the call that needs it. ## Where to go next [`Native HTTP API` Every endpoint the SDKs use, for any other language or for curl.](https://docs.kosa8.com/guides/native-api/) [`Python developer guide` Every option, explained.](https://docs.kosa8.com/guides/py-guide/) [`Go developer guide` Options, contexts and errors.Is.](https://docs.kosa8.com/guides/go-guide/) [`Ruby developer guide` Keywords, blocks and rescue.](https://docs.kosa8.com/guides/rb-guide/) [`Java developer guide` Builders, option objects and records.](https://docs.kosa8.com/guides/java-guide/) [`JavaScript and TypeScript` The SDK, the AI SDK adapter, MCP or the Docker API.](https://docs.kosa8.com/guides/js-overview/) --- Source: https://docs.kosa8.com/guides/sdks/ # Native HTTP API All five SDKs are thin clients for one HTTP API that the kosa8 daemon serves under `/kosa8/v1`. This page documents that API directly: how to reach it, the headers it reads, how streaming output is framed, how errors come back, and the request and response of every endpoint the SDKs use. Use it to write a client in another language, to call kosa8 from `curl`, or to understand exactly what an SDK call does on the wire. **In plain words** The daemon is a small web server that only listens on a file on your own disk, not on the network. You send it JSON, and it answers with JSON. Commands that produce output answer with a stream of small JSON lines, one per piece of output, ending with one that says the command has finished. The responses shown on this page came from a kosa8 0.9.36 daemon on an Apple silicon Mac. ## Reaching the daemon ### Local: the unix socket The daemon listens on `$HOME/.kosa8/run/kosa8.sock`. Any HTTP/1.1 client that can dial a unix socket works. The host name in the URL is ignored. ``` $ curl -s --unix-socket ~/.kosa8/run/kosa8.sock http://kosa8/kosa8/v1/health {"commit":"f6124c0","go":"go1.26.9","phase":"0-foundations","status":"ok","uptime_seconds":3396,"version":"0.9.36"} ``` The socket file is the access control: it is created mode `0600` in a directory only your user can enter, so only processes running as you can connect. No token is needed or checked locally. ### Remote: a worker over HTTPS A daemon can also serve other machines as a remote worker ([kosa8 worker](https://docs.kosa8.com/reference/worker/)). Clients then send every request with `Authorization: Bearer `; a missing or wrong token gets `401` with `WWW-Authenticate: Bearer`. The SDKs choose the endpoint the way the CLI does: | `KOSA8_HOST` (or the SDK's host option) | Endpoint | |---|---| | unset or empty | the local socket | | `https://host[:port][/path]` | HTTPS to that host; a path is kept as a prefix | | `http://host:port` | plain HTTP, **refused** unless the host is loopback (`localhost`, `127.x`, `::1`) or `KOSA8_INSECURE=1` / the SDK's insecure option is set | | `tcp://host:port` | the same as `http://` | | `host:port` | the same as `http://` | | `unix:///path/to.sock` | that socket file | | any other scheme | an error | `KOSA8_TOKEN` is only sent to a host that also came from `KOSA8_HOST`. A host passed in code gets only a token passed in code, so a token in your environment never leaks to a host chosen somewhere else. Plain HTTP is refused because the token, and every command you run, would cross the network in clear text; put the worker behind a TLS-terminating proxy or tunnel instead. ## Request headers | Header | Meaning | |---|---| | `Content-Type: application/json` | For JSON request bodies. Endpoints that take raw bytes (stdin) use `application/octet-stream`. | | `X-Kosa8-Actor` | Who is acting: `human`, `agent` or `tool`. The SDKs send `tool` unless told otherwise. It is recorded on the audit entry and policies can treat actors differently. | | `X-Kosa8-Agent-Token` | An agent identity minted by `kosa8 agent` ([Agent identity](https://docs.kosa8.com/guides/agent-identity/)). The SDKs read it from `KOSA8_AGENT_TOKEN`. Under a policy with `require_agent_identity` or `require_agent_owner`, requests without a valid one are refused. | | `X-Kosa8-Approval` | A signed approval for one action, when your organisation's policy requires one ([Company rules](https://docs.kosa8.com/guides/governance/)). | | `Authorization: Bearer …` | Remote workers only. | ## Responses and errors Successful answers are JSON with status `200` (or `201` for a created sandbox). Every error has the same shape: ``` $ curl -s --unix-socket ~/.kosa8/run/kosa8.sock -X POST \ -d '{"egress":"bogus"}' http://kosa8/kosa8/v1/sandboxes {"error":"egress policy \"bogus\": want any, deny, fakenet, or allow:HOST[,HOST...]","remediation":""} ``` `error` says what went wrong and `remediation`, when not empty, says what to do about it. The SDKs join them as *error (remediation)* and choose an error type by status: | Status | Meaning | SDK error | |---|---|---| | 400 | The request is malformed or a value is invalid. | BadRequest | | 401 | Missing or wrong bearer token (remote workers). | Auth | | 403 | Refused by policy. When the body also has an `approval` object with a `why`, a signed approval would allow it. | Policy, or ApprovalRequired | | 404 | No such sandbox, snapshot or container. | NotFound | | 409 | The request conflicts with the current state: a name or snapshot id is taken, a sandbox is not running, a sealed snapshot fails verification. | Conflict | | 501 | Not supported on this platform or build. | Unsupported | | 500 | The operation failed; the remediation names where to look. | Api | ## Streaming output: NDJSON frames Endpoints that run something (`run`, `exec`, `attach`) answer `200` immediately and then stream newline-delimited JSON, one *frame* per line, as the output is produced: ``` {"stream":"status","data":"UHVsbGluZy4uLg=="} {"stream":"stdout","data":"aGVsbG8K"} {"stream":"stderr","data":"d2FybmluZwo="} {"done":true,"ok":true,"exit":0} ``` | Field | Meaning | |---|---| | `stream` | `stdout`, `stderr`, or `status` (progress such as pulls) | | `data` | The bytes, base64-encoded, so binary output survives | | `done` | Present and `true` on the last frame only | | `exit` | The process's exit code, on the last frame | | `ok` | Whether the operation itself worked (a non-zero exit is still `ok`) | | `error` | On the last frame, when the operation failed after streaming began | A non-zero exit code is a normal result, not an error. A stream that ends without a `done` frame means the connection broke; the SDKs report that as a stream error and keep the output received so far. Errors found before streaming starts (no such sandbox, policy refusal) come back as an ordinary JSON error with its status code. ## Daemon ### GET /kosa8/v1/health Liveness and version. Returns `status`, `version`, `commit`, `go`, `uptime_seconds`. ### POST /kosa8/v1/pull Pulls an image into the host's shared engine. Body `{"ref":"alpine:latest"}`. ## Sandboxes ### GET /kosa8/v1/sandboxes Lists sandboxes as an array of sandbox objects: | Field | Meaning | |---|---| | `id`, `name` | Identifier and the name you gave it; either can be used in paths | | `state` | `running`, `paused` or `stopped` | | `created` | RFC 3339 time | | `parent` | For a fork, the sandbox it was copied from | | `cpus`, `mem_mb`, `disk_gb` | Resources | | `mounts` | Host folders shared in | | `egress` | The network policy string | | `ip` | Its address on the private network | | `labels` | Classification labels | | `capture`, `broker` | Packet capture file and credential broker, when set | ### POST /kosa8/v1/sandboxes Creates and boots a sandbox. Every field is optional. | Field | Type | Meaning | |---|---|---| | `name` | string | Name; generated when omitted | | `cpus` | int | Virtual CPUs | | `mem_mb` | int | Memory in MiB | | `disk_gb` | int | Private disk size in GiB | | `mounts` | array | Host folders to share, `host:guest[:ro]` | | `egress` | string | `deny`, `any`, `fakenet` or `allow:HOST[,HOST…]`. Hosts are names or IPv4 addresses and networks. When omitted, your organisation's default applies if a signed policy sets one, otherwise `any`. | | `capture` | object | Write the sandbox's packets to a pcapng file | | `labels` | object | Classification labels, checked against the org policy | | `broker` | object | Credential broker: real API keys stay on the host and the sandbox gets placeholders ([Keep API keys out](https://docs.kosa8.com/guides/credentials/)) | | `auto_stop_min` | int | Stop after this many minutes without use. 0 or omitted: never. | | `ttl_min` | int | Stop this many minutes after creation, in use or not | | `auto_delete_min` | int | Delete once it has been stopped this many minutes | | `ephemeral` | bool | Delete it, disk and all, whenever it stops | | `template` | string | Start with a template's image (see Templates below) | Answers `201` with `sandbox` (the object above) and `boot_ms`. With a broker it also returns `broker.url`, `broker.placeholders` and `broker.decide_token`, only in this response. ### Lifecycle: auto-stop, TTL, auto-delete, ephemeral Agents forget to clean up. A sandbox with a lifecycle cleans up after itself instead of holding memory and disk until someone notices. The daemon checks every 30 seconds. - **Auto-stop** (`auto_stop_min`) stops a sandbox nobody is using. It is in use while any of these is true: a request for it arrived within the window or is still being served (a long run, an open terminal); a container other than a session's idle shell is running in it; or a session has a process besides its own placeholder, such as something started with `nohup`. So a build left running keeps the sandbox up even when no request comes. If the daemon can't reach the guest to check, it counts the sandbox as in use. - **TTL** (`ttl_min`) is a deadline: the sandbox stops that long after it was created, in use or not. - **Auto-delete** (`auto_delete_min`) deletes a sandbox that has been stopped that long, however it was stopped. - **Ephemeral** deletes instead of stopping. When the sandbox is stopped by hand, by auto-stop or by its TTL, it and its disk are deleted. One left stopped by a daemon restart is deleted too. The settings are kept in the sandbox's record, so they survive a daemon restart. A restored or forked copy starts with none. Change them on a live or stopped sandbox with `POST /kosa8/v1/sandboxes/{id}/lifecycle`. Fields you leave out keep their value, and 0 turns one off. The answer is the sandbox, whose `lifecycle` field shows the result. From the CLI: ``` $ kosa8 sandbox create web --auto-stop 30m --auto-delete 24h $ kosa8 sandbox create scratch --ephemeral --ttl 2h $ kosa8 sandbox lifecycle web --auto-stop 0 ``` Under an org policy that requires signed approvals, changing a lifecycle needs one, because a lifecycle can delete a sandbox. Over MCP, `sandbox_create` takes `auto_stop_min`, `ttl_min` and `ephemeral`. ### POST /kosa8/v1/sandboxes/{id}/down and /up `down` stops a sandbox and keeps its disk; stopping one that is already stopped answers `{"ok":true,"already_stopped":true}`. `up` boots it again from its own disk and returns `sandbox`, `boot_ms` and any `warning`. ### POST /kosa8/v1/sandboxes/{id}/pause and /resume Freeze a running sandbox where it is, and continue it. `POST /kosa8/v1/sandboxes/pause` pauses every running sandbox at once and returns the ones it paused. ### DELETE /kosa8/v1/sandboxes/{id} Deletes a sandbox. With `?wipe=1` its disks are overwritten and verified first, and the answer includes a `wipe` summary and per-file `files`. Checkpoints are kept by the client, not the daemon, so the SDKs also delete the sandbox's checkpoint snapshots unless told to keep them, and add `checkpoints_dropped` to the result. ### POST /kosa8/v1/sandboxes/{id}/pull Pulls an image into the sandbox's own engine; code in a sandbox can only use images pulled into it. Body `{"ref":"python:3.12-alpine"}`, answer `{"ok":true,"image":"…"}`. ### GET /kosa8/v1/sandboxes/{id}/images, /containers, /egress The images in the sandbox (array of strings), its containers (array of objects), and its network policy with the decisions the host has made under it. ## Running code ### POST /kosa8/v1/sandboxes/{id}/run Runs a container to completion and streams its output as frames. Body: `{"image":"alpine:latest","args":["sh","-c","echo hi"],"env":["A=1"]}`. `args` replaces the image's command; `env` entries are `KEY=VALUE`. Add `"workspace":true` to mount the sandbox's workspace at `/workspace`, as `…/code` and sessions have it. ### POST /kosa8/v1/sandboxes/{id}/code Runs a snippet of Python, JavaScript or TypeScript and answers with one JSON object once it ends. You don't pick an image or quote anything for a shell: send the language and the source. ``` $ curl -s --unix-socket ~/.kosa8/run/kosa8.sock -X POST \ http://kosa8/kosa8/v1/sandboxes/demo/code \ -d '{"language":"python","code":"import sys\nprint(sys.version)"}' {"language":"python","image":"docker.io/library/python:3.12-slim","exit":0, "stdout":"3.12.…\n","stderr":"","artifacts":[],"duration_ms":412} ``` | Field | Meaning | |---|---| | `language` | `python`, `javascript` or `typescript` (also `py`, `js`, `node`, `ts`). | | `code` | The source, up to 96 KiB. For a bigger program, copy it into the sandbox and use `/run`. | | `image` | Optional. Defaults to `python:3.12-slim` or `node:22-slim`. If the sandbox doesn't have it yet, the daemon pulls it on the host and loads it in, so the sandbox needs no network access for that. | | `env` | Optional map of extra environment variables. | | `timeout_s` | Optional. Default 300, at most 3600. When the time runs out the snippet is killed and the answer has `"timed_out":true` and exit 124. | The snippet starts in the sandbox's workspace, `/workspace`, so files it writes there can be read back with the file API below, and files you upload are there for it to open. Python runs the source as `__main__`. If the snippet uses matplotlib, the daemon switches it to the non-GUI backend so `plt.show()` doesn't hang. When the snippet ends, every open figure comes back in `artifacts` as `{"type":"image/png","name":"figure-1.png","data":""}`. TypeScript runs on Node's built-in type stripping, so there is no compile step and types aren't checked. A snippet that fails still answers 200: read `exit` and `stderr`. Output beyond 32 MiB is dropped and `"truncated":true` is set. If the caller hangs up, the snippet is killed. Over MCP the same call is the `sandbox_code_run` tool. Its result is the output as text, followed by each chart as an image the model can look at. ### POST /kosa8/v1/sandboxes/{id}/start Starts a detached container and returns `{"ok":true,"id":"…"}`. Use it for a long-running process you then `exec` into. ### POST /kosa8/v1/sandboxes/{id}/exec Runs a command in a running container and streams frames. Body `{"container":"","args":["ls","-la"]}`. ### POST /kosa8/v1/sandboxes/{id}/start-interactive Starts a container with stdin held open: `{"image","args","env","tty"}`, returning its `id`. Feed it with `stdin` and read it with `attach`. ### POST /kosa8/v1/sandboxes/{id}/containers/{cid}/stdin The request body is written to the container's stdin as raw bytes. Send it with `Transfer-Encoding: chunked` to stream input as it is produced. Answers `{"ok":true}`. ### POST /kosa8/v1/sandboxes/{id}/containers/{cid}/attach Streams the container's output as frames until it exits. ## Files and git Every sandbox has a **workspace**: a folder on its own data disk that code runs see at `/workspace`. It survives a stop, and a snapshot or fork takes it along. These routes read and write it directly, without starting a container. A path is written as a container sees it (`/workspace/src/app.py`) or relative to the workspace (`src/app.py`). Any other absolute path is refused, and no path can leave the folder: the guest resolves every path, symlinks included, inside it. | Call | What it does | |---|---| | `GET …/files?path=` | Lists a folder: `{"path","files":[{"name","path","type","size","mode","mod_time","link"}]}`. `type` is `file`, `dir`, `symlink` or `other`. On a file it describes that file. | | `GET …/files/stat?path=` | Describes one entry without following a symlink. | | `GET …/files/content?path=` | Downloads a file, raw, with its `Content-Length`. | | `PUT …/files/content?path=&mode=644` | Uploads the body as the file, replacing it in one step (readers never see half a file) and making its folders. Up to 4 GiB. Answers with the new entry. | | `DELETE …/files?path=&recursive=true` | Removes a file, or a folder with `recursive`. The workspace itself can't be removed. | | `POST …/files/mkdir` | `{"path"}`: makes a folder and its parents. | | `POST …/files/move` | `{"from","to"}`: renames, making the destination's folders. | ``` $ S=~/.kosa8/run/kosa8.sock; B=http://kosa8/kosa8/v1/sandboxes/demo $ curl -s --unix-socket $S -X PUT --data-binary @data.csv "$B/files/content?path=/workspace/data.csv" $ curl -s --unix-socket $S -X POST $B/code -d '{"language":"python","code":"print(open(\"data.csv\").read()[:80])"}' ``` A missing path is 404. A path outside the workspace, a folder where a file was expected, or a non-empty folder removed without `recursive` is 400. A sandbox whose guest image predates the file API answers 501: upgrade kosa8, then stop and start the sandbox. ### POST /kosa8/v1/sandboxes/{id}/git Runs one git operation on a repository in the workspace. git runs in a container inside the sandbox, so a clone or push goes out under the sandbox's own network policy. A hook in a cloned repository runs sealed in there too. The image is `alpine/git`, which the daemon pulls on first use. Body: `{"op", "path", …}`, with the fields each operation reads: | op | Fields | Parsed result | |---|---|---| | `clone` | `url` (http or https), `path` (default: the repository's name), `branch`, `depth` | | | `status` | `path` | `status`: `{"branch","upstream","ahead","behind","clean","files":[{"path","from","index","worktree"}]}` | | `add` | `files` (default all) | | | `commit` | `message`, `author_name`, `author_email` | | | `push`, `pull` | `remote` (default origin), `branch` | | | `checkout` | `branch`, `create` | | | `branches` | | `branches`: `[{"name","current"}]` | | `log` | `n` (default 20) | `log`: `[{"hash","author_name","author_email","date","subject"}]` | | `diff` | `staged` | the diff is `stdout` | Every answer has `ok`, `exit`, `stdout` and `stderr`. A git failure (a conflict, an unknown branch) is still a 200 with `"ok":false`, so read `stderr`. For a private remote, pass `token` (and `username`, which defaults to `x-access-token`, what GitHub, GitLab and Gitea accept). The token reaches git as an HTTP header through its environment. It never appears on a command line, in the audit log or in an approval, and it is scrubbed from the output. ``` $ curl -s --unix-socket $S -X POST $B/git -d '{"op":"clone","url":"https://github.com/kosa8ai/kosa8-py","depth":1}' $ curl -s --unix-socket $S -X POST $B/git -d '{"op":"status","path":"kosa8-py"}' ``` Over MCP these are the `sandbox_file_list`, `sandbox_file_read`, `sandbox_file_write`, `sandbox_file_delete` and `sandbox_git` tools. ## Sessions and terminals ### Sessions: a shell that remembers An agent runs one command at a time, and expects `cd src` in one command to still hold in the next, and `export` too. A **session** gives it that. Each session is a long-lived container in the sandbox, which starts in `/workspace`. Each command runs in it with `sh`, and afterwards the shell's working directory and exported variables are saved for the next command. The daemon keeps no session state of its own: a session is its container, so a daemon restart, a snapshot or a fork carries it along. | Call | What it does | |---|---| | `POST …/sessions` | `{"name","image","env"}`, all optional. The image defaults to `python:3.12-slim`, pulled on first use. Answers `{"name","id","image","status"}`; 409 if the name is taken. | | `GET …/sessions` | The sandbox's sessions. | | `POST …/sessions/{name}/exec` | `{"command","timeout_s"}`. Answers `{"exit","stdout","stderr","cwd","duration_ms","timed_out","truncated"}`. `cwd` is where the shell is now. | | `DELETE …/sessions/{name}` | Ends the session and everything it started. | ``` $ curl -s --unix-socket $S -X POST $B/sessions -d '{"name":"main"}' $ curl -s --unix-socket $S -X POST $B/sessions/main/exec -d '{"command":"cd kosa8-py && export N=3"}' $ curl -s --unix-socket $S -X POST $B/sessions/main/exec -d '{"command":"echo $N; pwd"}' {"exit":0,"stdout":"3\n/workspace/kosa8-py\n","stderr":"","cwd":"/workspace/kosa8-py","duration_ms":31} ``` Commands in one session run one at a time, in the order they arrive. Only exported variables carry over; shell functions and aliases don't. A command that runs longer than `timeout_s` (default 300) is killed, and the session keeps the state it had before that command. To leave something running, start it in the background with its output redirected, for example `nohup ./server > server.log 2>&1 &`. Over MCP this is `sandbox_shell`, which makes the session (default `main`) on first use, and `sandbox_session_delete`. ### Terminals (PTY) For a program that needs a real terminal: a REPL, an editor, a TUI, or a person typing. | Call | What it does | |---|---| | `POST …/pty` | `{"image","command","cols","rows","env"}`, all optional. Starts a container with a terminal (by default a login shell in `/workspace`, with `TERM=xterm-256color`) and answers `{"id"}`. | | `POST …/containers/{cid}/resize` | `{"cols","rows"}`: sets the terminal's size. | | `GET …/containers/{cid}/tty` | A WebSocket that is the terminal itself. | On the WebSocket, send keyboard input as **binary** messages and control messages as **text**, for example `{"type":"resize","cols":120,"rows":40}`. The terminal's output arrives as binary messages. When the program ends, a text message `{"type":"exit","code":0}` arrives and the socket closes. A terminal's input can be opened once. A request that carries an `Origin` header, which every browser sends, is refused, so a web page can't open a terminal in your sandbox. A guest image from before resize support answers 501 to resize. ## Previews: a link to a web server in a sandbox An agent starts a web app in a sandbox and wants you to see it. A **preview** is a link that opens it in your browser and stops working when it expires. ``` $ curl -s --unix-socket $S -X POST $B/sessions/main/exec -d '{"command":"nohup python3 -m http.server 8000 > web.log 2>&1 &"}' $ curl -s --unix-socket $S -X POST $B/previews -d '{"port":8000,"session":"main","expires_in_s":1800}' {"url":"http://p-3f9c…a1.localhost:8791/?kosa8_preview=eyJz…","token":"eyJz…", "expires_at":"2026-11-03T13:30:00Z","container":"session-main","port":8000,"listen":"127.0.0.1:8791"} $ kosa8 sandbox preview demo 8000 --session main --expires 30m ``` Body: `port` (required); `session` or `container` for where the server runs, defaulting to the `main` session or the only running container; and `expires_in_s` (default 3600, at most 7 days). The server must listen on `0.0.0.0`, not `127.0.0.1`, so the sandbox's network can reach it. ### How it works - The daemon serves previews on a second listener, `127.0.0.1:8791` by default. Set `KOSA8_PREVIEW_ADDR` to move it. When kosa8 sits behind a proxy or tunnel with wildcard DNS, set `KOSA8_PREVIEW_URL` to a template such as `https://{host}.preview.example.com`. - Each preview has its own origin, `p-.localhost`, so one preview's page can't read another's cookies. Browsers resolve `*.localhost` to your own machine. - The link carries a token the daemon signs with a key only it holds (`~/.kosa8/keys/preview.key`). The token names the sandbox, the container, the port and the expiry. On the first visit the daemon trades the token for an HttpOnly cookie on that origin and redirects to the same page without it, so the token doesn't stay in the address bar or leak through a `Referer`. A program can send the token in the `X-Kosa8-Preview-Token` header instead. - The app sees ordinary requests, WebSockets included, with its own cookies. The preview's token is removed before they reach it. Preview traffic counts as use for auto-stop. - Nothing is stored per preview. A link works until it expires, until its sandbox is deleted (a new sandbox with the same name doesn't inherit it), or until `POST /kosa8/v1/previews/rotate` (`kosa8 sandbox preview --revoke-all`) replaces the key and ends every link at once. Under an org policy that requires signed approvals, making a preview needs one, because the link opens the sandbox's port to whoever holds it. Revoking does not. Over MCP this is `sandbox_preview`, which returns the link for the agent to give you. ## Templates: what a sandbox starts with A **template** is an image built from your Dockerfile and kept on your machine. A new sandbox created with `"template": "NAME"` starts with it already loaded. Its sessions, terminals and code runs then use that image unless a call names another. So an agent's `sandbox_shell` or `sandbox_code_run` finds your libraries, tools and data already there. ``` $ cat Dockerfile FROM python:3.12-slim RUN pip install --no-cache-dir pandas matplotlib scikit-learn $ kosa8 template build py-data . $ kosa8 sandbox create analysis --template py-data --auto-stop 30m $ kosa8 template ls $ kosa8 template rm py-data ``` The build runs in a sandbox made for it and deleted after it. That way the Dockerfile's `RUN` steps (an install script, a `curl | sh`) run sealed, never on your machine or the shared engine. They run under the egress you give: `any` by default, because builds fetch packages, or an allow-list such as `--egress allow:pypi.org,files.pythonhosted.org`. Base images are pulled by the host, under your organisation's registry allowlist when it has one. | Call | What it does | |---|---| | `POST /kosa8/v1/templates?name=&dockerfile=&buildarg=&egress=&replace=1` | The body is the build context as a tar, as for `/kosa8/v1/build`. Streams NDJSON frames (build output, then `{"done":true,"ok":true,"image":…}`). Names are lower-case. 409 if the template exists, unless `replace=1`. Optional `cpus`, `mem_mb` and `disk_gb` size the build sandbox. | | `GET /kosa8/v1/templates` | `[{"name","image","size_bytes","created","dockerfile"}]` | | `DELETE /kosa8/v1/templates/{name}` | Deletes it. Sandboxes made from it keep their copy. | A sandbox made from a template carries the labels `kosa8.io/template` and `kosa8.io/image`, and so do its snapshots, restores and forks. Templates live in `~/.kosa8/templates/` as OCI archives. ## Snapshots and forks ### POST /kosa8/v1/sandboxes/{id}/snapshot Captures memory, running processes and disks while the sandbox keeps running. Body `{"id":"optional-id","encrypt":false}`. Answers `snapshot` and `snapshot_ms`; with encryption, also `encryption` and `encrypt_ms`. An id already in use is `409`. If the org policy requires encryption, it happens anyway and the answer says `encrypted_by_policy`. ### GET /kosa8/v1/snapshots Lists snapshots: `id`, `sandbox`, `created`, `cpus`, `mem_mb`, `size_bytes` and more. ### POST /kosa8/v1/snapshots/{id}/restore Boots a new sandbox from a snapshot with its processes resumed. Body `{"name":"…","label":"…","allow_tampered":false}`. Answers `sandbox` and `restore_ms`, plus `seal` for a sealed snapshot. A sealed snapshot whose seal no longer verifies is refused with `409` unless `allow_tampered` is set, and the override is written to the audit log. ### DELETE /kosa8/v1/snapshots/{id} Deletes a snapshot, shredding its key first if it is encrypted. `?wipe=1` overwrites its files; `?force=1` is required to delete a sealed one. ### POST /kosa8/v1/sandboxes/{id}/fork Makes `n` live copies of a running sandbox from one snapshot. Body `{"n":3,"prefix":"try","label":""}`. Answers `forks` (sandbox objects), `snapshot` (the id they were made from), `fork_ms` and `count`. A failed fork keeps nothing, so it can be retried. Disks are cloned copy-on-write where the filesystem allows (APFS, XFS, btrfs). ## Built on the API: checkpoints and explore Two SDK features are client-side logic over the endpoints above, done the same way as the CLI: - **Checkpoints** are snapshots with ids `-ckpt-N`, tracked in `~/.kosa8/checkpoints.json` (mode 0600, rewritten atomically, locked with `flock` on `checkpoints.json.lock`). Ten are kept per sandbox; the oldest snapshot is deleted only after its replacement exists. **Rewind** deletes the sandbox, restores the chosen checkpoint under the same name, and discards checkpoints taken after it. - **Explore** pulls the image, forks the sandbox once per variant, runs `sh -c VARIANT` in each copy concurrently, keeps the fastest copy that exited 0 and deletes the rest. A copy that refuses its first connection while it settles is retried up to four times. ## Other endpoints The daemon serves more than the SDKs use today: networks, volumes, images (build, tag, save, load, push), ranges, labels, the audit log (`GET /kosa8/v1/audit` and `/audit/verify`), packet capture, snapshot sealing and encryption, and the malware lab. The [command reference](https://docs.kosa8.com/reference/) documents them through the CLI, which is built on the same API. The daemon also serves the Docker Engine API on the same socket ([For developers](https://docs.kosa8.com/guides/developers/)). --- Source: https://docs.kosa8.com/guides/native-api/ # Python quickstart One `hello.py`, five minutes. You will connect to kosa8 from Python, start a sealed sandbox with no network, run a command in it, save it, break it, put it back, and delete it. **How this page was tested** The program on this page was run, unchanged, against a real kosa8 daemon 0.9.36 on an Apple silicon Mac (macOS 26.0.1), with `kosa8==0.1.1` installed from PyPI by the `uv pip install` line below into a fresh virtual environment on CPython 3.13.13 (it was also run on 0.1.0, with the same results). The output shown is what the 0.1.1 run printed. Timings will differ on your machine. The `pip` lines under "Install the package" were not run on that machine (its Homebrew Python could not bootstrap pip; see [Errors and troubleshooting](https://docs.kosa8.com/guides/py-errors/#install)). ## What this is, in plain words A **sandbox** is a small, separate computer that runs inside your computer. It has its own operating system, its own files and its own network settings. Code running inside it cannot see your files, your passwords or your network unless you allow it. If the code does something bad, the damage stays inside the sandbox, and you can throw the sandbox away. That matters when code comes from somewhere you do not fully trust: an AI agent writing and running its own commands, a user's script on your server, a package you have not reviewed. kosa8 runs that code in a sandbox on your own machine, not in someone else's cloud. The **Python SDK** (the `kosa8` package on PyPI) lets a Python program do all of this: create sandboxes, run commands in them, read the output, save a sandbox's exact state (a **checkpoint**) and go back to it later (a **rewind**). It talks to the kosa8 background service, the **daemon**, that runs on your machine. It uses only Python's standard library, so it adds nothing else to your project. ## What you need | Part | Requirement | |---|---| | kosa8 | Installed, with its daemon running. See [Install](https://docs.kosa8.com/guides/install/). `kosa8 version` shows both parts. | | Machine | macOS on Apple silicon, x86-64 Linux with KVM, or Windows 11 on x86-64 (run your program inside WSL2). Sandboxes are real virtual machines. | | Python | 3.9 or later (the package's `requires-python` is `>=3.9`). No other packages. | | Disk | Room for the guest image the daemon downloads on first use (about 1.5 GB), plus sandboxes and checkpoints. | ``` $ kosa8 version kosa8 client 0.9.36 (f6124c0, darwin/arm64) kosa8 daemon 0.9.36 (up 4142s) ``` If the second line says the daemon is not running, start it with `kosa8 daemon start` (the Mac app starts it for you). ## Install the package Put it in a virtual environment, so it stays out of your system Python. With [uv](https://docs.astral.sh/uv/): ``` $ mkdir hello-kosa8 && cd hello-kosa8 $ uv venv -q .venv $ uv pip install --python .venv/bin/python kosa8==0.1.1 Resolved 1 package in 496ms Prepared 1 package in 71ms Installed 1 package in 3ms + kosa8==0.1.1 $ .venv/bin/python -c "import kosa8; print(kosa8.__version__)" 0.1.1 ``` Or with plain `pip` (not run for this page): ``` $ python3 -m venv .venv $ .venv/bin/pip install kosa8==0.1.1 ``` Use 0.1.1 or later: it fixes several bugs found while writing these pages (listed in the package's `CHANGELOG.md`). In a uv project, `uv add kosa8` adds it to `pyproject.toml`. The import name is the same as the package name: `import kosa8`. "Resolved 1 package" is the whole story: there are no dependencies. ## The walkthrough The steps below are parts of one file, `hello.py`. The whole file, and what it printed, is at the end. Everything after the sandbox is created sits inside a `try` block whose `finally` deletes the sandbox, so the clean-up runs even when a later step fails. ### Connect ``` from kosa8 import Kosa8Client # 1. Connect. With no arguments the client uses ~/.kosa8/run/kosa8.sock. k = Kosa8Client() print("kosa8", k.version(), "at", k.transport.target) ``` ``` kosa8 0.9.36 at unix:///Users/dhruvit/.kosa8/run/kosa8.sock ``` `Kosa8Client()` only works out where to connect; nothing is sent until the first call. `version()` asks the daemon for its version, which proves the connection works. If the daemon is not running, this is where you find out: it raises `Kosa8ConnectionError`, and the message says how to start it. ### Create a sandbox with no network ``` # 2. Create a sandbox: a microVM with its own kernel and no network at all. created = k.create_sandbox(name="doc-py-demo", egress="deny") sb = k.sandbox("doc-py-demo") try: info = created["sandbox"] print(f"sandbox {info['name']}: {info['state']}, egress {info['egress']}, booted in {created['boot_ms']}ms") ``` ``` sandbox doc-py-demo: running, egress deny, booted in 15310ms ``` `egress="deny"` closes the network completely. The rule is enforced on your computer, outside the sandbox, so code inside cannot turn it off. `k.sandbox(name)` returns a `Sandbox` handle with the name filled in, so later calls are shorter; it sends nothing by itself. The answer is the daemon's JSON as a plain dict. Booting took 15 seconds here because the machine was running other sandboxes; on an idle Mac it is usually a second or two. ### Pull an image and run a command ``` # 3. Pull an image into the sandbox (fetched on the host), then run a command to completion. sb.pull("alpine:latest") res = sb.run("alpine:latest", ["sh", "-c", "echo kernel $(uname -r); wget -q -T 3 -O- http://example.com || echo no network; exit 3"]) print(f"exit {res.exit_code} ok={res.ok}") print(res.stdout, end="") ``` ``` exit 3 ok=False kernel 6.18.35 no network ``` Code in a sandbox runs in containers on the sandbox's own engine, so it needs an image. `pull` fetches it on the host and copies it in, which works even though the sandbox has no network. `run` starts a fresh container, waits for it to finish and returns an `ExecResult`. The kernel is the sandbox's own, and the download failed, as it should. A command that exits with a non-zero code is a *result*, not an exception: `res.exit_code` is 3 and `res.ok` is `False`. An exception means the command could not run at all. ### Keep a container running ``` # 4. Keep a container running, and write a file in it. cid = sb.start_container("alpine:latest", ["sh", "-c", "while :; do sleep 3600; done"]) def sh(cmd): r = sb.exec(cid, ["sh", "-c", cmd]) return (r.stdout + r.stderr).strip() print("wrote /note:", sh("echo v1 > /note; cat /note")) ``` ``` wrote /note: v1 ``` `run` starts from a clean image every time. To keep files between commands, start a container that stays up and `exec` commands in it. `start_container` returns the container's id. ### Checkpoint, break something, rewind ``` # 5. Checkpoint, break something, rewind. cp = sb.checkpoint(label="before the risky bit") print(f"checkpoint {cp['checkpoint']['id']} ({cp['checkpoint']['label']}) in {cp['snapshot']['snapshot_ms']}ms") sh("rm /note") print("after rm:", sh("cat /note")) rw = sb.rewind() print(f"rewound to {rw['checkpoint']['id']} in {rw['restore_ms']}ms") print("after rewind: /note =", sh("cat /note")) ``` ``` checkpoint doc-py-demo-ckpt-1 (before the risky bit) in 761ms after rm: cat: can't open '/note': No such file or directory rewound to doc-py-demo-ckpt-1 in 480ms after rewind: /note = v1 ``` A checkpoint saves the whole running sandbox: memory, running programs and disks. `rewind()` with no argument goes back to the latest one. The sandbox returns under the same name with the same container still running, so the same container id works and the deleted file is back. The checkpoint is written to `~/.kosa8/checkpoints.json`, the same list the `kosa8` command line uses. ### Delete ``` finally: # 6. Always clean up: the sandbox and its checkpoints. print("deleted:", sb.delete()) ``` ``` deleted: {'ok': True, 'checkpoints_dropped': 1} ``` `delete()` removes the sandbox and, by default, its checkpoints and their snapshots too: `checkpoints_dropped` counts them. Pass `keep_checkpoints=True` to keep them. ## The whole file ``` from kosa8 import Kosa8Client # 1. Connect. With no arguments the client uses ~/.kosa8/run/kosa8.sock. k = Kosa8Client() print("kosa8", k.version(), "at", k.transport.target) # 2. Create a sandbox: a microVM with its own kernel and no network at all. created = k.create_sandbox(name="doc-py-demo", egress="deny") sb = k.sandbox("doc-py-demo") try: info = created["sandbox"] print(f"sandbox {info['name']}: {info['state']}, egress {info['egress']}, booted in {created['boot_ms']}ms") # 3. Pull an image into the sandbox (fetched on the host), then run a command to completion. sb.pull("alpine:latest") res = sb.run("alpine:latest", ["sh", "-c", "echo kernel $(uname -r); wget -q -T 3 -O- http://example.com || echo no network; exit 3"]) print(f"exit {res.exit_code} ok={res.ok}") print(res.stdout, end="") # 4. Keep a container running, and write a file in it. cid = sb.start_container("alpine:latest", ["sh", "-c", "while :; do sleep 3600; done"]) def sh(cmd): r = sb.exec(cid, ["sh", "-c", cmd]) return (r.stdout + r.stderr).strip() print("wrote /note:", sh("echo v1 > /note; cat /note")) # 5. Checkpoint, break something, rewind. cp = sb.checkpoint(label="before the risky bit") print(f"checkpoint {cp['checkpoint']['id']} ({cp['checkpoint']['label']}) in {cp['snapshot']['snapshot_ms']}ms") sh("rm /note") print("after rm:", sh("cat /note")) rw = sb.rewind() print(f"rewound to {rw['checkpoint']['id']} in {rw['restore_ms']}ms") print("after rewind: /note =", sh("cat /note")) finally: # 6. Always clean up: the sandbox and its checkpoints. print("deleted:", sb.delete()) ``` ``` $ .venv/bin/python hello.py kosa8 0.9.36 at unix:///Users/dhruvit/.kosa8/run/kosa8.sock sandbox doc-py-demo: running, egress deny, booted in 15310ms exit 3 ok=False kernel 6.18.35 no network wrote /note: v1 checkpoint doc-py-demo-ckpt-1 (before the risky bit) in 761ms after rm: cat: can't open '/note': No such file or directory rewound to doc-py-demo-ckpt-1 in 480ms after rewind: /note = v1 deleted: {'ok': True, 'checkpoints_dropped': 1} ``` If the program stops halfway (an exception, or Ctrl-C), the `finally` block still deletes the sandbox. If the process is killed outright, the sandbox is left running: remove it with `kosa8 sandbox rm doc-py-demo`. Running the program twice at the same time fails on the second create, because the name is taken (`Kosa8ConflictError`). ## Look at it from the command line The SDK and the CLI drive the same daemon and share the same checkpoint list, so you can watch what your program does, or finish a job by hand: ``` $ kosa8 sandbox ls $ kosa8 checkpoint ls doc-py-demo $ kosa8 snapshot ls ``` A checkpoint taken from Python shows up in `kosa8 checkpoint ls`, and `kosa8 rewind doc-py-demo` would go back to it; the other way round works too. **If the rewind fails on a Mac** Restoring a saved machine needs the screen to be unlocked. With the screen locked the checkpoint succeeds and the rewind fails. By then the rewind has already removed the sandbox; the checkpoint's snapshot is intact, and the `Kosa8CheckpointError` it raises ends with the `restore_snapshot(…)` call that brings it back. See [Python errors and troubleshooting](https://docs.kosa8.com/guides/py-errors/#rewind-failed). **Next** [Python developer guide](https://docs.kosa8.com/guides/py-guide/): every capability in detail, with patterns for AI agent loops. [Errors and troubleshooting](https://docs.kosa8.com/guides/py-errors/) and the [API reference](https://docs.kosa8.com/guides/py-api/) list everything else. --- Source: https://docs.kosa8.com/guides/py-quickstart/ # Python developer guide The whole `kosa8` package, capability by capability: what each call does, what it sends, what comes back, where it can surprise you, and how to put the pieces together into an AI agent that cannot hurt the machine it runs on. **How this page was tested** Every example that shows output was run against a real kosa8 daemon 0.9.36 on an Apple silicon Mac (macOS 26.0.1), with `kosa8` on CPython 3.13.13: first 0.1.0 from PyPI, then 0.1.1 (`uv pip install kosa8==0.1.1`). The examples whose behaviour changed in 0.1.1 (stdin, parallel checkpoints, the new `Sandbox` methods, the checkpoint lock timeout) were run again on 0.1.1 and show its output; the rest behave the same on both. The output is pasted from those runs, trimmed only where marked with `…`. Other people's sandboxes were running on the same machine, so timings are slow and will differ on yours. Not run: remote workers over `https`, signed approvals, encrypted or sealed snapshots, packet capture, the broker, and a restore on a locked screen; what this page says about them comes from the SDK's source and is marked as such. ## What this is, in plain words kosa8 gives each piece of untrusted code its own small computer, a **sandbox**, running on your machine. The Python SDK is a remote control for those sandboxes: a Python program can create one, run programs inside it, read what they print, take a photo of its complete state (a **checkpoint**), go back to that photo later (a **rewind**), make several identical copies (a **fork**) and try different ideas in the copies at once (an **explore**). The typical user is an AI agent. The model proposes a shell command; your program runs it in the sandbox instead of on your laptop; if the command goes wrong, you rewind and nothing outside the sandbox was touched. The network is closed unless you open it, so a confused or hijacked agent cannot send your data anywhere. The SDK does not run anything itself. It sends requests to the kosa8 **daemon** (`kosa8d`), the background service that owns the virtual machines, over a local file called a unix socket. Every call on this page is one or a few such requests. ## The parts, and the words for them | Word | In plain words | In the SDK | |---|---|---| | Daemon | The kosa8 service on your machine that does the work. | Reached by `Kosa8Client` over `~/.kosa8/run/kosa8.sock`, or a remote worker. | | Sandbox | A sealed small computer: its own Linux kernel, disk and network rules. | A microVM. A dict record; `Sandbox` is a handle to one. | | Image | A packaged set of programs, such as Alpine Linux or Python. | An OCI image reference, pulled into the sandbox's own engine. | | Container | One program started from an image, inside the sandbox. | `run` makes a throwaway one; `start_container` a long-lived one, by id. | | Egress | Whether the sandbox may reach the internet, and where. | `egress=` on create; enforced on the host. | | Snapshot | A saved copy of a running sandbox: memory, programs and disk. | `snapshot`, `restore_snapshot`. Named by you or the daemon. | | Checkpoint | An undo point. A snapshot kept on a numbered list per sandbox. | `checkpoint`, `rewind`. Kept in `~/.kosa8/checkpoints.json`. | | Fork | N live copies of one running sandbox. | `fork(n)`. | | Explore | Try N commands in N copies at once, keep the best copy. | `explore(variants)`. | ## Connecting In plain words: the client has to know where the daemon is. On your own machine it finds it by itself. To drive a kosa8 worker on another machine, you give it an address and a password (token). ``` from kosa8 import Kosa8Client k = Kosa8Client() # local daemon, ~/.kosa8/run/kosa8.sock print(k.transport.target, k.version()) print(k.health()) ``` ``` unix:///Users/dhruvit/.kosa8/run/kosa8.sock 0.9.36 {'commit': 'f6124c0', 'go': 'go1.26.9', 'phase': '0-foundations', 'status': 'ok', 'uptime_seconds': 4282, 'version': '0.9.36'} ``` Constructing a client sends nothing; it only resolves the endpoint and can raise `Kosa8Error` right away for an address it refuses or an unknown `actor`. All arguments are keyword-only: | Argument | Environment | Default | Effect | |---|---|---|---| | `socket_path` | none | `~/.kosa8/run/kosa8.sock` | The daemon's unix socket. Ignored when a host is in effect. | | `host` | `KOSA8_HOST` | none | A worker address: `https://h`, `http://h:p`, `tcp://h:p` (read as http), `h:p` (read as http), or `unix:///path` (a socket). A path after the host is kept as a prefix on every request. | | `token` | `KOSA8_TOKEN` | none | Bearer token for a TCP worker, sent as `Authorization: Bearer …`. Never sent over a unix socket. | | `insecure` | `KOSA8_INSECURE=1` | `False` | Allow plain `http` to a host that is not `localhost`, `::1` or `127.*`. | | `actor` | none | `"tool"` | `"human"`, `"agent"` or `"tool"`, sent as `X-Kosa8-Actor` for the audit log. | | `agent_token` | `KOSA8_AGENT_TOKEN` | none | An agent identity minted with `kosa8 agent token create NAME`, sent as `X-Kosa8-Agent-Token`. A bad one is refused with 401 on every call, even `health()`. | | `headers` | none | none | Extra headers on every request. The SDK's own headers are applied after them and win. | | `timeout` | none | `None` | Socket timeout in seconds; see [Timeouts]. | | `checkpoint_store` | none | `FileCheckpointStore()` | Where checkpoint lists live; see [Checkpoints]. | | `env` | none | `os.environ` | Where the `KOSA8_*` variables are read. `env={}` ignores the environment, which is useful in tests. | Two rules about the token are easy to miss. `KOSA8_TOKEN` is only picked up when the host also came from `KOSA8_HOST`; pass `host=` in code and you must pass `token=` too. And plain `http` to another machine is refused before anything is sent, because the token would cross the network in clear. You can check what the client will do without a daemon, with `resolve_endpoint`: ``` from kosa8 import resolve_endpoint print(resolve_endpoint(env={"KOSA8_HOST": "https://worker.example.com/k8/", "KOSA8_TOKEN": "s3cret"})) print(resolve_endpoint(host="https://worker.example.com", env={"KOSA8_TOKEN": "s3cret"})) print(resolve_endpoint(env={"KOSA8_HOST": "localhost:7070"})) ``` ``` Endpoint(kind='tcp', socket_path='', scheme='https', host='worker.example.com', port=None, base_path='/k8', token='s3cret') Endpoint(kind='tcp', socket_path='', scheme='https', host='worker.example.com', port=None, base_path='', token=None) Endpoint(kind='tcp', socket_path='', scheme='http', host='localhost', port=7070, base_path='', token=None) ``` The second line has `token=None`: the host was explicit, so `KOSA8_TOKEN` was not used. `KOSA8_HOST=unix:///Users/you/.kosa8/run/kosa8.sock` also works and points at a socket. HTTPS uses `ssl.create_default_context()`, so the system's certificate store and normal hostname checks apply; there is no option to turn verification off. (Remote workers were not tested for this page; the rules above are read from `kosa8/transport.py`.) ### How requests are made The transport is the standard library's `http.client`. It opens a new connection for every request and closes it after, so there is no pool, nothing to close on the client, and no state shared between calls apart from the checkpoint store. That makes one client safe to share between threads ([below]). Bodies are JSON; streamed answers (run, exec, attach) are NDJSON, one frame per line, read as they arrive. ## Sandboxes In plain words: create a sandbox, give it a name, decide how much of the network it may use, and delete it when you are done. A sandbox keeps running, and using memory, until you stop or delete it. ``` c = k.create_sandbox(name="doc-py-g1", egress_allow=["example.com"], cpus=2, mem_mb=1024, labels={"team": "docs"}) print(sorted(c), c["sandbox"]) ``` ``` ['boot_ms', 'sandbox'] {'id': 'doc-py-g1', 'name': 'doc-py-g1', 'created': '2026-11-03T01:39:06.472971-05:00', 'state': 'running', 'cpus': 2, 'mem_mb': 1024, 'disk_gb': 32, 'egress': 'allow:example.com', 'ip': '192.168.127.2', 'labels': {'team': 'docs'}} ``` | Parameter | Meaning | Notes | |---|---|---| | `name` | The sandbox's name, also its id. | Omitted: the daemon picks one (`sbx1` in a test run). A name in use: `Kosa8ConflictError`. | | `cpus`, `mem_mb`, `disk_gb` | Size of the VM. | Daemon defaults when omitted (the test sandboxes got a 32 GB disk). | | `egress` | Network rule. | `"deny"`, `"any"`, `"fakenet"`, `"allow:a.com,b.com"` or `{"allow": [...]}`. Omitted: the org policy's default. | | `egress_allow` | Shorthand for an allow-list. | Combining it with an `egress` other than `None`/`"deny"` raises `Kosa8Error` before sending. | | `mounts` | Host folders inside the sandbox. | `[{"host_path", "guest_path", "read_only"}]`. Anything mounted read-write is not protected by the sandbox. | | `capture` | Record the sandbox's packets to a file on the host. | `{"path", "max_bytes"?, "max_files"?}`. Untested here. | | `labels` | Your own tags. | Kept on the record; org policies can match them. | | `broker` | The egress broker: inject API keys on the host so the sandbox never sees them. | Passed through unchanged. See [Keep API keys out](https://docs.kosa8.com/guides/credentials/). Untested here. | | `approval` | A signed approval id. | See [approvals](https://docs.kosa8.com/guides/py-errors/#Kosa8ApprovalRequiredError). | A value that is `None`, empty or `0` is left out of the request, so `cpus=0` means "the default", not zero CPUs. ### Egress, in practice The allow-list above lets the sandbox reach `example.com` and nothing else. A name that is not on the list does not even resolve: ``` r = sb.run("alpine:latest", ["sh", "-c", "wget -q -T 5 -O- http://example.com | head -c 60; echo; wget -q -T 3 -O- http://kosa8.com || echo blocked"]) print(repr(r.stdout), repr(r.stderr)) print(sb.egress()) ``` ``` '&2"], env={"GREETING": "hi"}, on_stdout=lambda b: chunks.append(("out", b)), on_stderr=lambda b: chunks.append(("err", b)), on_status=lambda b: chunks.append(("status", b))) print(r.exit_code, repr(r.stdout), repr(r.stderr), chunks) ``` ``` 0 'hi\n' 'oops\n' [('err', b'oops\n'), ('out', b'hi\n')] ``` - `args` is an argument list, passed as is. There is no shell unless you run one, as here with `sh -c`. Quoting is then the shell's business, so never paste untrusted text into a `sh -c` string you also use for something else. - `env` is a dict (`{"K": "V"}`) or a list of `"K=V"` strings. Only `run` and `start_interactive` take it; `start_container` and `exec` do not. - Callbacks get `bytes`, in the calling thread, as each frame arrives. A chunk can be part of a line or several lines; the order between stdout and stderr is the order the daemon sent them. `on_status` gets the daemon's progress frames, which are not collected into the result (none were sent for this run). - The image must already be in the sandbox. `run` does not pull. **`ExecResult`** has `exit_code`, `stdout_bytes`, `stderr_bytes`, and the properties `stdout` and `stderr` (UTF-8, invalid bytes replaced) and `ok` (`exit_code == 0`). A non-zero exit is a result, never an exception. Output is kept in memory in full; for a command that prints gigabytes, use the callbacks or `exec_frames` and do not keep the result. ### `start_container` and `exec`: a long-lived workspace ``` long = sb.start_container("alpine:latest", ["sleep", "3600"]) # returns the container id r = sb.exec(long, ["sh", "-c", "echo v1 > /note; cat /note"]) print(sb.containers()) ``` The container's process must keep running (`sleep 3600`, or a `while` loop); when it exits, the container's status becomes `exited`, so there is nothing to `exec` into. `containers()` lists `{id, image, status, ip, exit?}`, with `status` `running` or `exited`. An `exec` into a container id that does not exist is not a 404: the stream ends with an error frame, raised as `Kosa8StreamError` (`no such container: nope`). ### `exec_frames`: the raw stream ``` for f in k.exec_frames(sb.id, long, ["sh", "-c", "for i in 1 2 3; do echo tick $i; sleep 0.3; done; exit 7"]): print(f) ``` ``` {'stream': 'stdout', 'data': b'tick 1\n'} {'stream': 'stdout', 'data': b'tick 2\n'} {'stream': 'stdout', 'data': b'tick 3\n'} {'done': True, 'ok': True, 'exit': 7} ``` It is a generator: nothing is sent until you iterate. `data` is already decoded from base64. The last frame has `done: True` and either `exit` or `error`. `exec_frames` does not raise for an error frame, it yields it; check `f.get("error")` yourself. Note `ok: True` next to `exit: 7`: `ok` means the stream worked, not that the command succeeded. Breaking out of the loop closes the connection; the SDK sends nothing to stop the command in the sandbox. ### Interactive: `start_interactive`, `write_stdin`, `attach` For a program that reads input, such as a REPL, a filter, or a tool that takes a prompt on stdin: ``` cid = sb.start_interactive("alpine:latest", ["sh", "-c", "tr a-z A-Z"]) print(sb.write_stdin(cid, [b"hello ", "from python\n"])) # an iterable is sent chunked print(sb.attach(cid, timeout=10)) ``` ``` {'ok': True} ExecResult(exit_code=0, stdout_bytes=b'HELLO FROM PYTHON\n', stderr_bytes=b'') ``` `data` can be `bytes`, `str` (UTF-8 encoded) or any iterable of them, such as a generator reading a file; an iterable is sent with chunked transfer encoding, so it is never held in memory whole. `tty=True` asks for a terminal. `attach` follows the container's output until it exits and returns an `ExecResult`. (On 0.1.0 these three are called on the client with `sb.id` first, as in `k.write_stdin(sb.id, cid, data)`; that form still works.) **One `write_stdin` call is the whole of stdin.** The daemon treats one request body as the program's entire input and closes stdin when the body ends. So the SDK allows one call per container on a client, and raises `Kosa8Error` on a second, before sending anything: ``` cid = sb.start_interactive("alpine:latest", ["cat"]) print(sb.write_stdin(cid, b"one\n")) try: sb.write_stdin(cid, b"two\n") except Kosa8Error as e: print(type(e).__name__, e) print(sb.attach(cid, timeout=10)) ``` ``` {'ok': True} Kosa8Error stdin of container k1-18b52980 in doc-py-v11 was already sent and is closed: the daemon treats one write_stdin body as the whole of stdin. Pass an iterable to stream input ExecResult(exit_code=0, stdout_bytes=b'one\n', stderr_bytes=b'') ``` To feed input over time, pass one generator that yields as you go. The check is per client object, keyed by the sandbox and container strings: a second `Kosa8Client`, or another process, is not stopped, and its input is simply never read. (0.1.0 had no check: the second call answered `{'ok': True}` and its bytes were lost.) ### Timeouts In plain words: a timeout says how long to wait for an answer before giving up. It does not stop the program in the sandbox. `timeout` (client-wide, or per call on `run`, `exec`, `exec_frames`, `attach`) is a *socket* timeout in seconds: it applies to connecting and to each read separately. A command that keeps printing within the limit can run far longer in total; a silent one fails after the limit. The default, `None`, waits forever. ``` try: sb.exec(cid, ["sh", "-c", "echo started; sleep 6; touch /done"], timeout=2) except Kosa8TimeoutError as e: print("timed out:", e) print("right after:", sb.exec(cid, ["sh", "-c", "ls /done 2>&1"]).stdout.strip()) time.sleep(6) print("6s later:", sb.exec(cid, ["sh", "-c", "ls /done 2>&1"]).stdout.strip()) r = sb.exec(cid, ["sh", "-c", "for i in 1 2 3 4 5; do echo $i; sleep 1; done"], timeout=2, on_stdout=lambda b: print(" got", b)) print("streamed run finished:", r.exit_code) ``` ``` timed out: POST /kosa8/v1/sandboxes/doc-py-g4/exec timed out mid-stream right after: ls: /done: No such file or directory 6s later: /done got b'1\n' … got b'5\n' streamed run finished: 0 ``` The command kept running after the timeout and created `/done`; the five-second loop finished under a two-second timeout because it printed every second. Two consequences: - To bound a command's *total* time, bound it inside the sandbox: `["timeout", "30", "sh", "-c", cmd]` (BusyBox and coreutils both have it). The exit code is then 124 or 143, a normal result. - The client-wide `timeout` also applies to `create_sandbox`, `snapshot`, `fork` and `restore_snapshot`, which take no per-call timeout and can be slow (a boot took 14 to 21 seconds on this busy machine; a restore 17). Leave the client timeout at `None` or generous, and set short ones per call. A timeout on `create_sandbox` sends no cancel, so the sandbox may still appear; check `get_sandbox(name)` before retrying. ## Snapshots In plain words: a snapshot is a saved copy of a running sandbox, with everything in memory, so programs carry on from where they were when you bring it back. You can bring it back under a new name as many times as you like. ``` s = sb.snapshot(id="doc-py-g2-manual") # id optional; the daemon picks one r = k.restore_snapshot("doc-py-g2-manual", name="doc-py-g2-copy") print(r["sandbox"]["name"], r["sandbox"]["state"], r["restore_ms"], r["sandbox"]["parent"]) print(k.exec("doc-py-g2-copy", srv, ["cat", "/count"]).stdout.strip()) # same container id print(k.delete_snapshot("doc-py-g2-manual")) ``` ``` doc-py-g2-copy running 17360 doc-py-g2 18 {'encrypted': False, 'ok': True, 'removed': 'doc-py-g2-manual'} ``` | Call | Options | |---|---| | `snapshot(sandbox, id=None, encrypt=False)` | Returns `{snapshot: record, snapshot_ms}`. An id already taken: `Kosa8ConflictError` (`snapshot already exists: …`). `encrypt=True` asks for an encrypted snapshot (untested here). | | `list_snapshots()` | Every snapshot on the daemon, checkpoints included. Filter on `record["sandbox"]`. | | `restore_snapshot(id, name=None, label=None, allow_tampered=False)` | Boots a new sandbox from the snapshot, processes resumed. `name` must be free. `allow_tampered` restores a sealed snapshot whose seal no longer verifies; leave it off. | | `delete_snapshot(id, wipe=False, force=False)` | `force` is needed for a sealed snapshot. | The copy above is a separate sandbox with `parent` set, and the container from the original (`srv`, a counter writing `/count` every second) was running in it under the same id. A snapshot keeps the egress rule of the sandbox it came from. ## Fork ``` f = sb.fork(2, prefix="doc-py-g2-f") print({k_: v for k_, v in f.items() if k_ != "forks"}) print([(x["id"], x["state"], x.get("parent")) for x in f["forks"]]) ``` ``` {'count': 2, 'fork_ms': 2985, 'snapshot': 'doc-py-g2-fork-1793688049891573000'} [('doc-py-g2-f1', 'running', 'doc-py-g2'), ('doc-py-g2-f2', 'running', 'doc-py-g2')] ``` `fork(n, prefix=None, label=None)` snapshots the sandbox once and restores it `n` times, named `1 … n`. The parent keeps running. The forks are ordinary sandboxes: you delete each one yourself. The intermediate snapshot was not left in `kosa8 snapshot ls` after the forks were deleted. ## Explore: best of N In plain words: you have several ideas and do not know which works. Explore makes one copy of the sandbox per idea, tries them all at the same time, keeps the copy where the idea worked fastest and throws the others away. The original is not touched. ``` r = sb.explore(["sleep 2; echo slow", "echo fast", "exit 1"], prefix=name + "-x") print("winner:", r.winner, "removed:", r.removed) for b in r.branches: print(" ", b) ``` ``` winner: sbx1-x2 removed: 2 ExploreBranch(name='sbx1-x1', variant='sleep 2; echo slow', exit=0, output='slow', duration_ms=2959, error=None) ExploreBranch(name='sbx1-x2', variant='echo fast', exit=0, output='fast', duration_ms=993, error=None) ExploreBranch(name='sbx1-x3', variant='exit 1', exit=1, output='', duration_ms=968, error=None) ``` What it does, step by step (it is client-side, in `Kosa8Client.explore`): 1. Raises `Kosa8Error` if there are fewer than two variants. 2. If `pull=True` (default), pulls `image` (default `"alpine:latest"`) into the parent. 3. Forks the parent once per variant, with `prefix` (default `-explore-<5 digits of the time>`). 4. Runs every variant as `["sh", "-c", variant]` in its own branch, all at once on a thread pool. Each run has a socket timeout of `timeout` (default 120 s). An error that looks like a connection problem is retried up to four attempts (0.5, 1, 1.5 s apart; from 0.1.1 there is no extra wait after the last one); a timeout is not. 5. The winner is the fastest branch with exit 0 and no error; `""` if none. `keep="branch-name"` overrides the choice. 6. Unless `keep_all=True`, deletes every other branch. The winner is left running for you to use, and to delete. The whole call took 54 seconds on this machine, almost all of it the pull and the fork; the branches themselves took one to three seconds. `duration_ms` includes retries. ## Checkpoints and rewind In plain words: a checkpoint is an undo point. Take one before anything risky. If it goes wrong, rewind, and the sandbox is exactly as it was, with its programs still running. kosa8 keeps the last ten per sandbox. ``` cp1 = sb.checkpoint(label="first") print(cp1["checkpoint"], cp1["evicted"], sorted(cp1["snapshot"])) cp2 = sb.checkpoint(label="second") print([(c["id"], c["label"]) for c in sb.checkpoints()]) print(subprocess.run(["kosa8", "checkpoint", "ls", "doc-py-g2"], capture_output=True, text=True).stdout) rw = sb.rewind(to="FIRST") # an id, or a label in any case print(rw["checkpoint"]["id"], rw["dropped"], rw["restore_ms"], rw["sandbox"]["state"]) print([c["id"] for c in sb.checkpoints()]) ``` ``` {'id': 'doc-py-g2-ckpt-1', 'sandbox': 'doc-py-g2', 'label': 'first', 'created': '2026-11-03T06:40:07.019767Z'} [] ['snapshot', 'snapshot_ms'] [('doc-py-g2-ckpt-1', 'first'), ('doc-py-g2-ckpt-2', 'second')] CHECKPOINT WHEN TAKEN WHY doc-py-g2-ckpt-1 just now you first *doc-py-g2-ckpt-2 just now you second * is where `kosa8 rewind doc-py-g2` goes doc-py-g2-ckpt-1 ['doc-py-g2-ckpt-2'] 574 running ['doc-py-g2-ckpt-1'] ``` A counter process that wrote `/count` every second read 3 at the checkpoint, 3 right after the rewind, and 4 a second later: memory and the process came back and carried on. ### How it works The daemon has no checkpoint endpoint. A checkpoint is a snapshot with the id `-ckpt-`, plus an entry in a list the client keeps. The SDK does this the same way the Go tools do (`pkg/checkpoint`), on the same file: - **`checkpoint(label="")`**: works out the next number (one more than the larger of the list's length and the highest number used), takes the snapshot, then, if the list now has more than `MAX_CHECKPOINTS_PER_SANDBOX` (10), deletes the oldest snapshots, then records the new entry. Eviction happens only after the new snapshot exists. Returns `{checkpoint, evicted, snapshot}`. - **`rewind(to=None)`**: finds the target (the latest, or the newest entry whose id equals `to` or whose label equals it ignoring case) *before* touching anything, so a bad `to` raises `Kosa8CheckpointError` and changes nothing. Then it deletes the sandbox, restores the snapshot under the same name (if that restore fails, `Kosa8CheckpointError` says the sandbox was deleted, the checkpoint is intact, and which `restore_snapshot` call brings it back; see [a rewind that failed halfway](https://docs.kosa8.com/guides/py-errors/#rewind-failed)), cuts the list after the target and deletes the snapshots of the later checkpoints (`dropped`). The state since the target is gone; take a checkpoint first if you might want it. - **`list_checkpoints(sandbox)`** / `sb.checkpoints()`: the list, oldest first, read from the file; no request to the daemon. - **`delete_sandbox`** removes the sandbox's list and its snapshots, unless `keep_checkpoints=True`. With the list kept, a later sandbox with the same name inherits it. The list is keyed by the string you pass as `sandbox`. Ids and names are the same in kosa8 0.9.36 (an unnamed sandbox got id and name `sbx1`), so either works, but use one consistently. ### The shared file: `~/.kosa8/checkpoints.json` The default store, `FileCheckpointStore()`, is the file the kosa8 CLI, the MCP server and the Mac app use. That is why `kosa8 checkpoint ls` above showed the checkpoints taken from Python, and why `kosa8 rewind doc-py-g2` would have gone to the newest one. The format is `{"sandbox-name": [{"id", "sandbox", "label", "created"}, …]}`. - Each change takes an in-process lock, then an exclusive `fcntl.flock` on `~/.kosa8/checkpoints.json.lock` (the same lock the Go tools take), reads the file, changes it, writes `checkpoints.json.tmp` with mode 0600 and renames it into place. Readers never see a half-written file. The directory is created with mode 0700. From 0.1.1 the `flock` is retried every 50 ms for at most `lock_timeout` seconds (default 10), then `Kosa8CheckpointError` is raised instead of waiting forever. Tested by holding the lock from another process: ``` Kosa8CheckpointError timed out after 1s waiting for …/ck/checkpoints.json.lock; another kosa8 process is holding it ``` - with `FileCheckpointStore(path, lock_timeout=1)`. Nothing was written. - Reads (`list_checkpoints`, finding the next id) do not take the lock. - A missing or corrupt file reads as empty, so a damaged file never makes a sandbox unusable; the next write replaces it. - On Windows there is no `fcntl`, and only the in-process lock is taken. (kosa8 sandboxes do not run on Windows; this matters only for a client talking to a remote worker.) Other stores: ``` from kosa8 import Kosa8Client, FileCheckpointStore, MemoryCheckpointStore isolated = Kosa8Client(checkpoint_store=MemoryCheckpointStore()) # this process only per_job = Kosa8Client(checkpoint_store=FileCheckpointStore("/srv/job-42/checkpoints.json")) patient = Kosa8Client(checkpoint_store=FileCheckpointStore(lock_timeout=60)) # 0.1.1: wait longer for the lock ``` `MemoryCheckpointStore` forgets everything when the process ends, and the CLI cannot see its checkpoints; the snapshots themselves still exist on the daemon, so delete the sandbox through the same client to clean them up. Any object with `load() -> dict` and `update(fn) -> result` (call `fn` on the data, save it, return what `fn` returned) can be a store, for example one backed by your database. ### Two checkpoints of one sandbox at once The next checkpoint number is read before the snapshot is taken, outside the lock, so two checkpoints of the same sandbox at the same moment (two threads, two processes, or your program and the CLI) can pick the same id. From 0.1.1, the one whose snapshot is refused with `snapshot already exists` steps to the next number and tries again, up to ten attempts; a 409 for any other reason is raised as before. Two threads, on 0.1.1: ``` def cp(i): return sb.checkpoint(label=f"t{i}")["checkpoint"]["id"] with ThreadPoolExecutor(2) as p: print(list(p.map(cp, range(2)))) print([(c["id"], c["label"]) for c in sb.checkpoints()]) ``` ``` ['doc-py-v11-ckpt-1', 'doc-py-v11-ckpt-2'] [('doc-py-v11-ckpt-1', 't0'), ('doc-py-v11-ckpt-2', 't1')] ``` On 0.1.0 the same test gave one checkpoint and a `Kosa8ConflictError: snapshot already exists: doc-py-race-ckpt-1`; on that version, serialize checkpoints per sandbox. Which checkpoint gets the lower number is a matter of timing, and the list is ordered by creation time, so with parallel checkpoints "latest" means "finished last". ## Threads and asyncio The SDK is synchronous; there is no async client. Because every request uses its own connection, one `Kosa8Client` can be shared by many threads: ``` def job(i): return sb.run("alpine:latest", ["sh", "-c", f"sleep 1; echo job {i}"]).stdout.strip() with ThreadPoolExecutor(4) as pool: print(list(pool.map(job, range(4)))) ``` ``` ['job 0', 'job 1', 'job 2', 'job 3'] # 1.7 s for four 1-second jobs ``` From asyncio, run each call in a worker thread with `asyncio.to_thread` (Python 3.9+), so the event loop is never blocked: ``` async def main(): with temp_sandbox(k, prefix="doc-py-async", egress="deny") as sb: # recipe below await asyncio.to_thread(sb.pull, "alpine:latest") results = await asyncio.gather(*[ asyncio.to_thread(sb.run, "alpine:latest", ["sh", "-c", f"echo task {i}"]) for i in range(3) ]) print("async:", [r.stdout.strip() for r in results]) asyncio.run(main()) ``` ``` async: ['task 0', 'task 1', 'task 2'] ``` Output callbacks then run in the worker thread, not the event loop. To pass chunks to async code, use `loop.call_soon_threadsafe(queue.put_nowait, chunk)`. Cancelling the awaiting task does not stop the thread or the command; bound the command inside the sandbox ([Timeouts]). What is *not* safe in parallel: a rewind of a sandbox while other threads are running commands in it (they fail while it is deleted and restored). ## Recipes ### A sandbox that always cleans up ``` import contextlib, uuid from kosa8 import Kosa8NotFoundError @contextlib.contextmanager def temp_sandbox(client, prefix="agent", **create): """A sandbox that is deleted when the block ends, whatever happens inside.""" name = f"{prefix}-{uuid.uuid4().hex[:6]}" client.create_sandbox(name=name, **create) sb = client.sandbox(name) try: yield sb finally: try: sb.delete() except Kosa8NotFoundError: pass # already gone ``` A random suffix means two runs never fight over a name. If `create_sandbox` itself raises, there is nothing to clean up. A process killed with `SIGKILL` skips `finally`; sweep leftovers at start-up with `[s for s in k.list_sandboxes() if s["name"].startswith(prefix)]`, or label your sandboxes and filter on the label. ### An AI agent loop with undo The pattern: one long-lived container per task so files persist between the model's commands; a checkpoint before each step; a rewind when your own check says the step was bad. The stand-in "model" below proposes three commands, one of them destructive. ``` k = Kosa8Client(actor="agent") class Workspace: """One long-lived container that an agent's commands run in, so files persist.""" def __init__(self, sb, image="alpine:latest"): self.sb = sb sb.pull(image) self.cid = sb.start_container(image, ["sh", "-c", "mkdir -p /work; while :; do sleep 3600; done"]) def sh(self, cmd, timeout=60): r = self.sb.exec(self.cid, ["sh", "-c", f"cd /work && {cmd}"], timeout=timeout) return r.exit_code, (r.stdout + r.stderr)[-4000:] # cap what goes back to the model proposals = [ "echo 'draft 1' > notes.txt && cat notes.txt", "rm -rf /work/*", # a destructive step "ls /work", ] with temp_sandbox(k, prefix="doc-py-agent", egress="deny") as sb: ws = Workspace(sb) for turn, cmd in enumerate(proposals, 1): ws.sb.checkpoint(label=f"turn {turn}") # a restore point before every step code, out = ws.sh(cmd) print(f"turn {turn}: exit {code}: {out.strip()!r}") if "rm -rf" in cmd: # your own check: tests, a reviewer, a policy rw = ws.sb.rewind(to=f"turn {turn}") print(f" undone: back to {rw['checkpoint']['id']}, dropped {rw['dropped']}") print(" /work now:", ws.sh("ls /work")[1].strip()) print("checkpoints:", [(c["id"], c["label"]) for c in sb.checkpoints()]) ``` ``` turn 1: exit 0: 'draft 1' turn 2: exit 0: '' undone: back to doc-py-agent-2b097e-ckpt-2, dropped [] /work now: notes.txt turn 3: exit 0: 'notes.txt' checkpoints: [('doc-py-agent-2b097e-ckpt-1', 'turn 1'), ('doc-py-agent-2b097e-ckpt-2', 'turn 2'), ('doc-py-agent-2b097e-ckpt-3', 'turn 3')] ``` Notes for real agents: - The container id survives the rewind, so `Workspace` keeps working after it. - Give the model the exit code and the tail of the output as the tool result, not an exception message. Reserve exceptions for "the sandbox is broken": catch `Kosa8StreamError` and `Kosa8TimeoutError` per step and report them to the model as a failed step; let `Kosa8ConnectionError` stop the loop. - A checkpoint took under a second in these tests (746 ms in the quickstart). Every turn is affordable for a coding agent; for hundreds of tiny steps, checkpoint before the risky ones only. Past ten, the oldest are evicted automatically. - Keep `egress="deny"` unless the task needs the network, then use an allow-list. API keys the agent needs belong in the broker, not in `env`; see [Securing an agent](https://docs.kosa8.com/guides/secure-agents/). - `actor="agent"` marks these calls as an agent's in the audit log; an `agent_token` names which agent. ### Stream output to a user as it happens ``` import sys r = sb.exec(cid, ["sh", "-c", "make test"], on_stdout=lambda b: sys.stdout.buffer.write(b) or sys.stdout.flush(), on_stderr=lambda b: sys.stderr.buffer.write(b) or sys.stderr.flush()) print("\nexit", r.exit_code) ``` (Not run as shown; the callback mechanism is the one tested above.) ### Try several fixes, keep the one that passes ``` r = sb.explore([ "cd /work && patch -p1 < /fixes/a.diff && make test", "cd /work && patch -p1 < /fixes/b.diff && make test", ], image="my-build-image:latest", timeout=600) if r.winner: print("keep", r.winner) # a running sandbox with the fix applied and tested else: for b in r.branches: print(b.name, b.exit, b.error or b.output[-500:]) ``` (A pattern, not run as shown: the paths and image are placeholders. `explore` runs each variant in a fresh container of `image`, so the files it needs must be in that image or on the sandbox's disk.) ## Errors, briefly Every exception the SDK raises is a `Kosa8Error`. An error from the daemon keeps the daemon's own words in `daemon_message` and its advice in `remediation`, and its class says what kind of problem it is (`Kosa8NotFoundError`, `Kosa8ConflictError`, `Kosa8PolicyError`, …). Every class, with real messages and what to do: [Python errors and troubleshooting](https://docs.kosa8.com/guides/py-errors/). **See also** [Quickstart](https://docs.kosa8.com/guides/py-quickstart/) for a first program; [API reference](https://docs.kosa8.com/guides/py-api/) for exact signatures and return shapes; [Native HTTP API](https://docs.kosa8.com/guides/native-api/) for the endpoints underneath; [SDKs in five languages](https://docs.kosa8.com/guides/sdks/) to compare with the other clients. --- Source: https://docs.kosa8.com/guides/py-guide/ # OpenAI Agents SDK and LangChain One function call turns a kosa8 sandbox into the tools an agent framework expects. The agent writes and runs code in its own microVM; your machine is not in reach. **Versions** Needs `kosa8` 0.2.0 or later and a daemon with the toolbox routes (the release after v0.12.1). The adapters were tested with `openai-agents` and `langchain-core` against the SDK's mock daemon: the tool schemas, and calls going through to the right routes. They have not yet been run against a live daemon. ## Install ``` pip install 'kosa8[openai-agents]' # the OpenAI Agents SDK pip install 'kosa8[langchain]' # LangChain or LangGraph ``` ## OpenAI Agents SDK ``` from agents import Agent, Runner from kosa8 import Kosa8Client from kosa8.integrations.openai_agents import sandbox_tools client = Kosa8Client() sbx = client.create_sandbox(name="coder-1", ephemeral=True, auto_stop_min=15) agent = Agent( name="coder", instructions="You work in a Linux sandbox. Keep your files in /workspace.", tools=sandbox_tools(client, sbx["id"]), ) result = Runner.run_sync(agent, "Write fizzbuzz.py, run it for 1..15 and show the output.") print(result.final_output) client.stop_sandbox(sbx["id"]) # ephemeral: stopping deletes it ``` ## LangChain and LangGraph ``` from langchain_anthropic import ChatAnthropic from langgraph.prebuilt import create_react_agent from kosa8 import Kosa8Client from kosa8.integrations.langchain import sandbox_tools client = Kosa8Client() sbx = client.create_sandbox(name="coder-2", ephemeral=True) agent = create_react_agent(ChatAnthropic(model="claude-sonnet-5-5"), sandbox_tools(client, sbx["id"])) agent.invoke({"messages": [("user", "Clone https://github.com/pallets/click and run its tests.")]}) ``` The tools are `StructuredTool`s, so they also work with `bind_tools` and any LangChain agent. ## The tools | Tool | What it does | |---|---| | `sandbox_run_code` | Runs Python, JavaScript or TypeScript. Starts in `/workspace`. Reports charts it drew. | | `sandbox_shell` | Runs a command line. `cd` and `export` carry to the next call. | | `sandbox_read_file` | Reads a text file from the workspace. | | `sandbox_write_file` | Creates or replaces a file, making its folders. | | `sandbox_list_files` | Lists a folder; folders end in `/`. | | `sandbox_git` | clone, status, add, commit, push, pull, checkout, branches, log, diff. | | `sandbox_preview` | A signed link to a web server the agent started, which expires. | Pick a subset with `tools=["shell", "read_file"]`, change the prefix with `name_prefix=`, and pass a signed approval to every call with `approval=`. Long output is cut to 20,000 characters, keeping the start and the end. ## When something is refused A tool never raises a kosa8 error into the framework. It returns text starting with `error:`, with the daemon's reason and what to do, so the model can change course: ``` error: egress to pypi.org is not allowed by this sandbox's policy what to do: create the sandbox with egress_allow=["pypi.org", "files.pythonhosted.org"] ``` Open the network only as far as the task needs, when you create the sandbox: ``` client.create_sandbox(name="coder-3", egress_allow=["pypi.org", "files.pythonhosted.org"]) ``` ## Another framework The adapters are thin. `kosa8.integrations.sandbox_functions(client, sandbox)` returns the same tools as plain Python functions with type hints and docstrings. Most frameworks (CrewAI, Pydantic AI, smolagents, the Anthropic SDK's tool runner) turn such functions into tools directly. ``` from kosa8.integrations import sandbox_functions fns = sandbox_functions(client, "coder-1", tools=["run_code", "shell"]) print(fns[1]("cd /tmp && pwd")) ``` ## Without a framework The same calls are on the client, for your own loop: ``` sb = client.sandbox("coder-1") r = sb.run_code("import sys; print(sys.version)") print(r.exit_code, r.stdout) sb.shell("cd /workspace && python3 -m venv .venv") sb.shell(". .venv/bin/activate && pip --version") # still in /workspace, venv still active sb.write_file("notes.md", "# done\n") ``` See also: [RL rollouts and evals](https://docs.kosa8.com/guides/py-rl-eval/), the [Python developer guide](https://docs.kosa8.com/guides/py-guide/) and the [native API](https://docs.kosa8.com/guides/native-api/). --- Source: https://docs.kosa8.com/guides/py-frameworks/ # RL rollouts and evals An eval or an RL run needs many copies of one environment, each sealed from the others and from your machine, reset cheaply between episodes. A kosa8 sandbox is that copy; a snapshot is the reset. **Versions** Uses `kosa8` 0.2.0 (`run_code`, `shell`, files, lifecycle) and calls that 0.1.x already had (`fork`, `checkpoint`, `rewind`, `explore`). The patterns below are built from those calls; they have not been benchmarked here, so measure the timings on your machine. ## The shape of it 1. **Prepare once.** Make a sandbox, install the task's dependencies, copy in the repo. Save it as a template or keep it as a snapshot. 2. **Fan out.** `fork(n)` makes *n* running copies of that state. 3. **Roll out.** Each policy or model works in its own copy through the toolbox. 4. **Score.** Run the hidden tests in the copy and read the exit code. 5. **Throw away.** Delete the copies. The prepared state is untouched. ## 1. Prepare the environment ``` from kosa8 import Kosa8Client client = Kosa8Client() base = client.create_sandbox(name="task-base", egress_allow=["pypi.org", "files.pythonhosted.org"]) sb = client.sandbox(base["id"]) sb.git("clone", url="https://github.com/pallets/click", path="repo") sb.shell("cd /workspace/repo && pip install -q -e . pytest") ``` For an environment you will reuse across runs, put the setup in a Dockerfile and build a template once: `client.build_template("click-env", "./env")`. Then every sandbox starts from it with `create_sandbox(template="click-env")`, with no install step. ## 2. Fork one rollout per sample ``` f = client.fork(base["id"], 8, prefix="ep-") episodes = [x["id"] for x in f["forks"]] # ep-1 … ep-8, all running, all identical ``` Forking snapshots the base once and restores it eight times. The base keeps running. Each fork has its own disk, memory and network rules, so one episode cannot see or spoil another. ## 3. Run the policy in each copy Give each episode its own tools. With the OpenAI Agents SDK (see [agent frameworks](https://docs.kosa8.com/guides/py-frameworks/)): ``` from concurrent.futures import ThreadPoolExecutor from agents import Agent, Runner from kosa8.integrations.openai_agents import sandbox_tools TASK = "Fix the failing test in /workspace/repo. Do not edit the tests." def rollout(sid): agent = Agent(name="solver", instructions=TASK, tools=sandbox_tools(client, sid)) return Runner.run_sync(agent, TASK, max_turns=30).final_output with ThreadPoolExecutor(8) as pool: transcripts = list(pool.map(rollout, episodes)) ``` The client is safe to share between threads; each call is its own request. ## 4. Score with a reward the agent cannot see Copy the grader in only after the rollout, so the policy cannot read or edit it, and score on the exit code: ``` GRADER = open("hidden_tests.py").read() def reward(sid): sb = client.sandbox(sid) sb.write_file("/workspace/.grader/test_hidden.py", GRADER) r = sb.shell("cd /workspace/repo && python -m pytest -q /workspace/.grader", timeout_s=300) return 1.0 if r.ok else 0.0 rewards = [reward(sid) for sid in episodes] ``` For a partial score, have the grader print JSON and parse `r.stdout`. With `run_code` you can score in Python directly and get back charts as PNG bytes in `r.artifacts`. ## 5. Clean up ``` for sid in episodes: client.delete_sandbox(sid) ``` Or let kosa8 do it: give the base `ttl_min=` or `auto_stop_min=`, and make throwaway sandboxes `ephemeral=True`, so they are deleted the moment they stop, even if your script crashes. ## Branching inside an episode For search (tree search, best-of-N per step, undoing a bad action), take a checkpoint before a risky step and rewind if it fails. The rest of the episode is unaffected: ``` client.checkpoint("ep-1", label="before migration") r = client.shell("ep-1", "cd /workspace/repo && ./migrate.sh") if not r.ok: client.rewind("ep-1") # back to the checkpoint: disk and memory ``` To try several next actions at once and keep the copy where one worked, fork the episode once per action, run each in its own copy, and keep the first that passes: ``` patches = ["patch-a.diff", "patch-b.diff", "patch-c.diff"] tries = [x["id"] for x in client.fork("ep-1", len(patches), prefix="ep-1-try")["forks"]] with ThreadPoolExecutor(len(tries)) as pool: oks = list(pool.map(lambda t: client.shell( t[0], f"cd /workspace/repo && git apply /workspace/{t[1]} && python -m pytest -q").ok, zip(tries, patches))) winner = next((sid for sid, ok in zip(tries, oks) if ok), None) for sid in tries: if sid != winner: client.delete_sandbox(sid) ``` `explore()` does this in one call for commands that need only an image, not the workspace: each variant runs in a fresh container of `image`, and it keeps the fastest copy that exits 0. See [explore in depth](https://docs.kosa8.com/guides/py-guide/#explore). ## Evals over a dataset The same pattern runs a benchmark: one forked sandbox per task, the model's answer applied, the task's tests run, the pass rate counted. ``` results = [] for task in dataset: # each: {"id", "setup", "check"} sid = client.fork(base["id"], 1, prefix=f"eval-{task['id']}-")["forks"][0]["id"] try: client.shell(sid, task["setup"]) answer = model_solve(task, sandbox_tools(client, sid)) results.append(client.shell(sid, task["check"]).ok) finally: client.delete_sandbox(sid) print(f"pass rate {sum(results)}/{len(results)}") ``` ## Things to watch - **Network:** sandboxes start with the network closed. Open only what setup needs, and consider a separate base for rollouts with egress denied, so a policy cannot fetch answers. - **Resources:** every fork is a microVM with its own memory. Size `mem_mb` on the base, and keep the number of live forks to what the machine holds. - **Time:** pass `timeout_s` to `shell` and `run_code`, so a hung episode ends with `timed_out=True` rather than blocking the batch. - **Audit:** every command an agent ran is in the kosa8 audit log, which helps when a reward looks too good. See also: [explore and fork in depth](https://docs.kosa8.com/guides/py-guide/#explore) and [explore](https://docs.kosa8.com/guides/explore/). --- Source: https://docs.kosa8.com/guides/py-rl-eval/ # Python errors and troubleshooting Every exception the `kosa8` package raises, with a real message from kosa8 0.9.36 and what to do about it, then a table of problems you can meet and how to fix them. **How this page was tested** The messages on this page were produced by `kosa8` 0.1.0 and 0.1.1 from PyPI on CPython 3.13.13 against a kosa8 daemon 0.9.36 on an Apple silicon Mac (macOS 26.0.1), by scripts that provoke each error on purpose. The ones that could not be provoked on that machine without changing its org policy or locking its screen, namely `Kosa8PolicyError`, `Kosa8ApprovalRequiredError`, `Kosa8UnsupportedError` and the failed rewind, are described from the SDK's source (`kosa8/errors.py`, `kosa8/client.py`) and say so. So is `Kosa8ProtocolError`, which needs a daemon that sends a broken stream. Where 0.1.1 changed what is raised, the page shows 0.1.1 and says what 0.1.0 did. ## What this is, in plain words When something goes wrong, the SDK raises an **exception**: Python's way of stopping and saying what happened. kosa8's exceptions are sorted into kinds, so your program can react to each kind differently: "the daemon is not running" calls for a different response than "that sandbox does not exist" or "the company policy forbids this". Most messages come straight from the kosa8 daemon and end with advice in brackets: what to do next. The SDK keeps the problem and the advice in separate attributes, so a program can show the advice to a person, or hand it to an AI agent as the reason its step failed. One thing is *not* an exception: a command in the sandbox that fails. If `make test` exits with code 2, `run` or `exec` returns an `ExecResult` with `exit_code == 2`. An exception means kosa8 itself could not do what you asked. ## The hierarchy ``` Exception └─ Kosa8Error anything this SDK raises; also client-side checks ├─ Kosa8ConnectionError the daemon could not be reached ├─ Kosa8TimeoutError a socket timeout passed ├─ Kosa8StreamError run, exec or attach ended with an error frame (output kept) ├─ Kosa8ProtocolError a streamed frame was not valid NDJSON (0.1.1) ├─ Kosa8CheckpointError checkpoints: none to rewind to, bad name, lock timeout, failed rewind └─ Kosa8ApiError the daemon answered with an HTTP error (any status, e.g. 500) ├─ Kosa8BadRequestError 400 ├─ Kosa8AuthError 401 ├─ Kosa8PolicyError 403 │ └─ Kosa8ApprovalRequiredError 403 with a signed-approval refusal ├─ Kosa8NotFoundError 404 ├─ Kosa8ConflictError 409 └─ Kosa8UnsupportedError 501 ``` All of them are importable from `kosa8`. Catch the specific class first and `Kosa8Error` last; catch `Kosa8ApprovalRequiredError` before `Kosa8PolicyError`, since it is a subclass. Plain `Kosa8Error` (not a subclass) is raised for checks the SDK makes before sending anything, and for a second `write_stdin` (0.1.1). On 0.1.0 a failed rewind was also a plain `Kosa8Error`; from 0.1.1 it is a `Kosa8CheckpointError`, which existing `except Kosa8Error` handlers still catch. ## Attributes | Attribute | Set on | Meaning | |---|---|---| | `message`, `str(e)` | all | For a daemon error: `" ()"`, as the CLI prints it. | | `status` | `Kosa8ApiError` and subclasses | The HTTP status. `None` elsewhere. | | `daemon_message` | daemon errors, stream errors | The daemon's `error` text, verbatim (or its `message`, or a text body). | | `remediation` | daemon errors, when the daemon gave one | What to do next. `None` for many 400s and 401s. | | `method`, `path` | daemon, connection and timeout errors | The request, such as `DELETE /kosa8/v1/sandboxes/doc-py-nope`. | | `body` | daemon errors | The decoded response body, a dict for JSON. | | `__cause__` | connection and timeout errors, failed rewinds | The underlying exception (`raise … from`), such as `FileNotFoundError(2, …)`. | | `stdout`, `stderr` | `Kosa8StreamError` | Output produced before the error, as `str`. | | `approval` | `Kosa8ApprovalRequiredError` | The daemon's refusal as a dict: `why`, `request_command`, `signers`, … | ``` from kosa8 import * def show(label, fn): try: fn(); print(label, "-> no error") except Kosa8Error as e: print(f"{label} -> {type(e).__name__} status={e.status}\n message: {e}\n" f" daemon_message: {e.daemon_message}\n remediation: {e.remediation}\n {e.method} {e.path}") k = Kosa8Client() show("not found delete", lambda: k.delete_sandbox("doc-py-nope")) ``` ``` not found delete -> Kosa8NotFoundError status=404 message: no such sandbox: doc-py-nope (list sandboxes with `kosa8 sandbox ls`) daemon_message: no such sandbox: doc-py-nope remediation: list sandboxes with `kosa8 sandbox ls` DELETE /kosa8/v1/sandboxes/doc-py-nope ``` Its `body` was `{'error': 'no such sandbox: doc-py-nope', 'remediation': 'list sandboxes with `kosa8 sandbox ls`'}`. The other messages below come from the same `show` helper. ## Each class ### `Kosa8ConnectionError` **When:** the request never reached a daemon. Any `OSError` while connecting or sending becomes this: no socket file, a file that is not a socket, a path too long, nothing listening on a TCP port. ``` kosa8 daemon unreachable at unix:///tmp/no-daemon-here.sock: [Errno 2] No such file or directory (is kosa8 installed and its daemon running? start it with `kosa8 daemon start`) kosa8 daemon unreachable at http://127.0.0.1:1: [Errno 61] Connection refused (is kosa8 installed and its daemon running? start it with `kosa8 daemon start`) kosa8 daemon unreachable at unix:///etc/hosts: [Errno 38] Socket operation on non-socket kosa8 daemon unreachable at unix:///tmp/xxxx…xxxx/kosa8.sock: AF_UNIX path too long ``` **What to do:** start the daemon (`kosa8 daemon start`, then `kosa8 daemon status`) or fix the address. `e.__cause__` is the original `OSError`; `e.__cause__.errno` is 2 (no file) or 61 (refused) on macOS. Nothing was done on the daemon side, so the call is safe to retry once it is up. The "is kosa8 installed" hint is added only for "no such file" and "refused". ### `Kosa8TimeoutError` **When:** you set a `timeout` (on the client or the call) and no bytes arrived for that many seconds, while connecting, waiting for the answer to start, or between two pieces of a streamed answer. ``` POST /kosa8/v1/sandboxes/doc-py-g1/exec timed out talking to unix:///Users/dhruvit/.kosa8/run/kosa8.sock POST /kosa8/v1/sandboxes/doc-py-g4/exec timed out mid-stream ``` **What to do:** know that the work was *not* cancelled. In testing, a command that timed out on the client side went on to finish in the sandbox. Before retrying something that is not idempotent (creating a sandbox, taking a snapshot with a fixed id), check whether it happened. To limit how long a command may run, limit it inside the sandbox (`["timeout", "30", …]`). Details: [Timeouts](https://docs.kosa8.com/guides/py-guide/#timeouts). ### `Kosa8StreamError` **When:** a `run`, `exec` or `attach` started streaming and then ended with an error frame instead of an exit code, or the stream stopped with no final frame at all. A container id that does not exist is reported this way, not as a 404: ``` Kosa8StreamError 'no such container: nope' # status None, daemon_message 'no such container: nope' stream ended unexpectedly (no final frame) # the connection dropped mid-run (from the source) ``` **What to do:** `e.stdout` and `e.stderr` hold what the command printed before the error; log them. A dropped stream usually means the daemon restarted or the sandbox was deleted or rewound from somewhere else mid-command; check `get_sandbox(name)`. `exec_frames` never raises this: it yields the error frame for you to inspect. ### `Kosa8CheckpointError` **When:** the client-side checkpoint bookkeeping refuses, before any request is sent. ``` no checkpoints for "doc-py-never" — take one with checkpoint() or `kosa8 checkpoint doc-py-never` no checkpoint "nope" for sandbox "doc-rb-db" invalid sandbox name "../etc" ``` **What to do:** for the first two, list what exists with `list_checkpoints(name)` or `kosa8 checkpoint ls NAME`; `to=` takes a checkpoint id or a label (case-insensitive). A rewind that raises this has changed nothing: the target is looked up before the sandbox is touched. The name check refuses empty names, `.`, `..`, names containing `/` or `\`, and names starting with a dot, because the name is a key in the shared checkpoint file. Two more cases from 0.1.1. The checkpoint file's lock is waited for at most `lock_timeout` seconds (`FileCheckpointStore(path=None, *, lock_timeout=10.0)`); tested by holding the lock from another process with `lock_timeout=1`: ``` timed out after 1s waiting for …/ck/checkpoints.json.lock; another kosa8 process is holding it ``` Something else (the CLI, the Mac app, another program) held the lock that long; nothing was written, so retry, or raise `lock_timeout`. On 0.1.0 the call waited forever. And a [rewind that failed halfway] is this class too. ### `Kosa8ProtocolError` (0.1.1) **When:** a line of a streamed answer (`run`, `exec`, `exec_frames`, `attach`) is not valid JSON, or its `data` is not valid base64. (From the source; a working daemon does not send such frames.) The message has the form `POST /kosa8/v1/sandboxes/X/exec: the daemon sent a frame that is not valid NDJSON (): `, with `method` and `path` set and the decoding error as `__cause__`. On 0.1.0 a raw `json.JSONDecodeError` escaped instead. **What to do:** check that `KOSA8_HOST` points at a kosa8 worker and not at a proxy that rewrites or buffers responses, and that client and daemon versions match (`kosa8 version`). The command may have run; check before retrying. ### Plain `Kosa8Error`: checks before sending **When:** the SDK refuses an argument itself. Nothing reaches the daemon. ``` refusing to talk to 10.0.0.5:7070 over plain http: the token and every request would cross the network unencrypted. Use https://10.0.0.5:7070 (a worker behind a TLS-terminating tunnel or proxy), or set insecure=True / KOSA8_INSECURE=1 if this link is already private and trusted KOSA8_HOST scheme "ftp" is not supported; use http://, https://, tcp:// or unix:// actor must be human, agent or tool, not 'robot' an egress allow-list needs at least one host; use egress="deny" for no network egress_allow already limits the network to the hosts it lists; do not also set egress give at least two variants; with one there is nothing to compare ``` The first three come from `Kosa8Client(...)` itself, so a bad `KOSA8_HOST` fails at construction, not at the first call. Also from the source: `KOSA8_HOST '…' names no host`, and from `explore`, `forked 1 branches for 2 variants; see list_sandboxes() for what was created` and `keep X is not one of the branches (…); all of them were kept`. **What to do:** fix the argument; retrying cannot help. ### `Kosa8CheckpointError`: a rewind that failed halfway **When:** `rewind` deleted the sandbox (that has to happen first, because the restored copy takes its name) and then the restore was refused. (From the source; not provoked for this page.) On 0.1.1 it is a `Kosa8CheckpointError` with this message, and `__cause__` is the daemon's error: ``` restore failed and doc-py-demo no longer exists (it was deleted to free its name for the restore): — the checkpoint is intact; recover with restore_snapshot('doc-py-demo-ckpt-1', name='doc-py-demo') ``` (0.1.0 raised a plain `Kosa8Error` saying `restore failed and doc-py-demo is now stopped: …`, which was wrong: the sandbox has been deleted, not stopped.) **What to do:** fix the cause, then run exactly the call the message gives. The snapshot is intact and the checkpoint list was not changed. On a Mac the usual cause is a locked screen: macOS will not restore a saved VM while the screen is locked (see the table below). The sandbox is gone from `list_sandboxes()` until you restore it; its name is free again but its disk files may remain under `~/.kosa8/sandboxes/`, and a `create_sandbox` with that name then fails ([below]). ### `Kosa8BadRequestError` (400) **When:** the daemon refused a value. Most often an egress rule. ``` egress allow "*.com": com is a public suffix, under which anyone can register a name, so the wildcard would admit strangers' hosts; allow a domain below it, as in *.example.com egress policy "sometimes": want any, deny, fakenet, or allow:HOST[,HOST...] ``` **What to do:** fix the input; the same request fails the same way. These carry no `remediation`; the message is the explanation. ### `Kosa8AuthError` (401) **When:** an agent token was sent and is not valid, or a remote worker's bearer token is missing or wrong. A bad agent token is never treated as "no token": every call is refused, even `health()`. ``` agent token is not recognised; mint one with `kosa8 agent token create NAME` ``` **What to do:** check `KOSA8_AGENT_TOKEN` and `agent_token=` (the environment variable is used when the argument is not given), mint a new token, or for a remote worker set `token=` / `KOSA8_TOKEN`. Remember that `KOSA8_TOKEN` is ignored when you pass `host=` in code. See [Agent identity](https://docs.kosa8.com/guides/agent-identity/). ### `Kosa8PolicyError` (403) **When:** the org policy forbids the action outright: a registry not on its allow-list, a label rule, a network rule wider than policy allows. (Not provoked for this page, to leave the test machine's policy alone; the JavaScript page shows a real one, `policy "…" forbids registry "docker.io" (allowed: ghcr.io)`, from the same daemon code.) **What to do:** do something the policy allows, or ask whoever manages it. `kosa8 policy show` prints the active policy. Retrying does not help. For an agent, pass the message back as the step's result: it says exactly what is not allowed. ### `Kosa8ApprovalRequiredError` (403) **When:** the policy wants named people to approve this exact action, and the call brought no usable approval. The SDK raises this subclass when a 403 body has an `approval` dict with a string `why`. (From the source; not provoked for this page.) ``` from kosa8 import Kosa8ApprovalRequiredError, Kosa8PolicyError try: k.delete_sandbox("runner") except Kosa8ApprovalRequiredError as e: print("why:", e.approval["why"]) print("ask for it with:", e.approval.get("request_command")) print("signers:", e.approval.get("signers")) approval_id = input("approval id: ") # from a person, out of band k.delete_sandbox("runner", approval=approval_id) except Kosa8PolicyError as e: print("not allowed:", e) ``` **What to do:** show `e.approval` to a person, wait for the approval id, then repeat the same call with `approval=id`; it is sent as the `X-Kosa8-Approval` header. An approval covers one call. Never let an agent invent or fetch its own approval. Note that `checkpoint`, `rewind` and `delete_sandbox` make several requests and pass the same `approval` to each. See [Company rules](https://docs.kosa8.com/guides/governance/). ### `Kosa8NotFoundError` (404) **When:** no such sandbox. ``` no such sandbox: doc-py-nope (list sandboxes with `kosa8 sandbox ls`) ``` **What to do:** on clean-up paths, ignore it: the thing is already gone. Elsewhere, check the name. `get_sandbox` returns `None` instead of raising. Two things that sound like 404s are not: a missing container is a [stream error], and a missing snapshot is a [500]. ### `Kosa8ConflictError` (409) **When:** the request conflicts with the current state. ``` sandbox doc-py-e2 already exists (choose another name, or remove the existing sandbox with `kosa8 sandbox rm NAME` (`kosa8 sandbox ls` lists them)) sandbox data disk already exists: /Users/dhruvit/.kosa8/sandboxes/doc-py-guide-data.img (name in use?) (choose another name, or remove the existing sandbox with `kosa8 sandbox rm NAME` (`kosa8 sandbox ls` lists them)) snapshot already exists: doc-py-g2-manual (choose another id, or delete the existing snapshot first) sandbox is not running: doc-py-g2-copy (start it with `kosa8 sandbox up doc-py-g2-copy`) ``` **What to do:** for a name in use, pick another name (a random suffix avoids it) or delete the old sandbox. The second message is the awkward one: no sandbox of that name is listed, but its disk files are still on disk, typically after a rewind that failed halfway. If a snapshot of it exists (`kosa8 snapshot ls`), restore it under that name; otherwise use another name. For "not running", `start_sandbox(name)`; that is a cold boot, so containers do not come back. From 0.1.1, `checkpoint` handles a snapshot id taken by a concurrent checkpoint itself (it steps to the next number); on 0.1.0 that surfaced as this error (see the table). ### `Kosa8UnsupportedError` (501) **When:** the daemon's platform cannot run sandboxes: they need macOS on Apple silicon, or Linux with KVM. (From the source; the test machine supports sandboxes.) **What to do:** run on supported hardware, or point the client at a worker that is (`KOSA8_HOST`). On Linux, check that `/dev/kvm` exists and that the daemon's user can open it, and run `kosa8 doctor`. ### `Kosa8ApiError` (any other status) **When:** the daemon answered with a status that has no class of its own, in practice 500. Two met while testing: ``` no such snapshot: doc-py-nope-snap (list snapshots with `kosa8 snapshot ls`) # restore_snapshot, status 500 guest not ready on vsock port 1024 after 25s (run `kosa8 doctor`; check ~/.kosa8/log/) # create_sandbox, status 500 ``` **What to do:** read `daemon_message`; the class alone says little. The second one happened while the machine was booting many sandboxes for other jobs at once; the same create succeeded when run again a minute later. It is the one error on this page worth retrying automatically, a couple of times with a pause, after deleting any half-made sandbox of that name. If it keeps happening, run `kosa8 doctor` and read `~/.kosa8/log/kosa8d.log`. ## A sensible way to catch them ``` from kosa8 import (Kosa8Error, Kosa8ConnectionError, Kosa8TimeoutError, Kosa8StreamError, Kosa8ApprovalRequiredError, Kosa8PolicyError, Kosa8NotFoundError) def agent_step(ws, cmd): """Run one model-proposed command; return text for the model, raise only if kosa8 is down.""" try: code, out = ws.sh(cmd) return f"exit {code}\n{out}" except Kosa8TimeoutError: return "the command produced no output for 60 s and was abandoned (it may still be running)" except Kosa8StreamError as e: return f"the command could not finish: {e.daemon_message}\n{e.stdout}{e.stderr}" except Kosa8ApprovalRequiredError as e: return f"needs a human approval: {e.approval['why']}" except Kosa8PolicyError as e: return f"refused by policy: {e}" # Kosa8ConnectionError, Kosa8NotFoundError (sandbox gone) and the rest propagate: # the loop cannot continue, so stop it and clean up. ``` (A pattern; `ws` is the `Workspace` from the [developer guide](https://docs.kosa8.com/guides/py-guide/#recipes).) ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| | `Kosa8ConnectionError … [Errno 2] No such file or directory` | The daemon is not running, or `HOME` is different from the daemon's (the socket is `$HOME/.kosa8/run/kosa8.sock`). | `kosa8 daemon start`. Under a service manager or in CI, check `HOME`, or pass `socket_path=`. | | `… [Errno 61] Connection refused` | The socket file is left from a daemon that died, or nothing listens on the TCP port. | `kosa8 daemon start`; for TCP, check the worker's address and port. | | `… AF_UNIX path too long` | macOS limits a unix socket path to 104 bytes. Deep temporary `HOME` folders in CI or test harnesses hit it. | A shorter `HOME`, or a short symlink to it, for both the daemon and your program. | | `Kosa8Error: refusing to talk to … over plain http` at `Kosa8Client()` | `KOSA8_HOST` is `http://` or a bare `host:port` to another machine. | Use `https://`. Only for a private, trusted link: `insecure=True` or `KOSA8_INSECURE=1`. | | Remote worker answers 401 although `KOSA8_TOKEN` is set | You passed `host=` in code; `KOSA8_TOKEN` is read only when the host comes from `KOSA8_HOST`. | Pass `token=` as well, or use `KOSA8_HOST`. | | Your program talks to the wrong daemon | `KOSA8_HOST` is set in the environment and wins over `socket_path`. | `print(k.transport.target)`; unset it, or pass `env={}` to ignore the environment. | | `Kosa8ConflictError: sandbox X already exists` | The name is taken, often by an earlier run that was killed before its clean-up. | Random suffixes; a clean-up sweep at start; `kosa8 sandbox rm X`. | | `sandbox data disk already exists: …-data.img (name in use?)` | No sandbox of that name is listed, but its disk files remain, usually after a rewind whose restore failed. | Restore the checkpoint under that name if `kosa8 snapshot ls` has one (`restore_snapshot(id, name=…)`), or use another name. | | `guest not ready on vsock port 1024 after 25s` (500) | The VM booted too slowly, typically on a machine busy booting other VMs. | Retry after a pause; delete any half-created sandbox first. Persisting: `kosa8 doctor`, `~/.kosa8/log/`. | | `run` fails because the image is missing | `k.pull(ref)` pulls into the host engine, not into a sandbox; `run` does not pull. | `sb.pull(ref)` (`pull_into_sandbox`) once per sandbox. | | Files written by one `run` are gone in the next | Each `run` is a fresh container from the image. | `start_container` once, then `exec` into it. | | `Kosa8StreamError: no such container: …` | The container id is wrong, its process exited, or the sandbox was stopped and started (a cold boot keeps no containers). | Check `sb.containers()`; keep the container's main process alive (`sleep 3600`); use checkpoints, not stop/start, to keep processes. | | `Kosa8ConflictError: sandbox is not running` | The sandbox was stopped, or the daemon restarted (which stops every sandbox). | `start_sandbox(name)`, then start your containers again. | | `Kosa8TimeoutError` on a long command | The command printed nothing for `timeout` seconds. The timeout is per read, not total. | Raise the timeout, make the command print progress, or bound it inside: `["timeout", "600", …]`. | | `Kosa8TimeoutError` on `create_sandbox`, `snapshot` or `fork` | A client-wide `timeout` applies to calls that have no per-call one, and boots and restores can take tens of seconds on a busy machine. | Keep the client timeout at `None` or generous; set short ones per `run`/`exec`. | | The program in the sandbox kept running after a timeout or Ctrl-C | Timeouts and interrupts stop the client waiting; nothing tells the sandbox to stop. | Bound commands inside the sandbox, or delete or rewind the sandbox to stop everything. | | `Kosa8Error: stdin of container … was already sent and is closed` (0.1.1), or on 0.1.0 a second `write_stdin` answers `{'ok': True}` and the program never sees it | The daemon closes stdin when the first `write_stdin` body ends; 0.1.1 refuses the second call on the same client and container. | Send all input in one call: pass a generator that yields chunks as you produce them. | | `exec_frames` ends with `{'done': True, 'ok': True, 'exit': 7}` | `ok` means the stream succeeded, not the command. | Read `exit`; use `exec` and `ExecResult.ok` if you want the simple answer. | | `rewind`, `fork`, `explore` or `restore_snapshot` fails on a Mac; checkpoints work | macOS refuses to restore a saved VM while the screen is locked (the VM's saved state is protected by a key usable only while the Mac is unlocked). | Keep the Mac unlocked while agents run, or run them on Linux. After a failed `rewind`, run the `restore_snapshot(…)` its message gives. See [the full explanation](https://docs.kosa8.com/guides/js-troubleshooting/#locked). | | `Kosa8ConflictError: snapshot already exists: X-ckpt-N` from `checkpoint` (0.1.0) | Two checkpoints of the same sandbox at the same moment picked the same number. Reproduced with two threads on 0.1.0; on 0.1.1 the same test produced `ckpt-1` and `ckpt-2`. | Upgrade to 0.1.1, which steps to the next number. On 0.1.0: one checkpoint at a time per sandbox. | | `Kosa8CheckpointError: timed out after 10s waiting for …checkpoints.json.lock` (0.1.1) | Another process held the checkpoint file's lock for longer than `lock_timeout`. | Retry; find what holds it (a stuck `kosa8` process); or `FileCheckpointStore(lock_timeout=60)`. | | `kosa8 checkpoint ls` does not show checkpoints taken from Python | The client was given a `MemoryCheckpointStore` or a `FileCheckpointStore` with another path, or runs with another `HOME`. | Use the default store; check `k.checkpoints.store`. | | Checkpoints of a deleted sandbox reappear on a new sandbox with the same name | It was deleted with `keep_checkpoints=True`, or by a tool that does not clean the list. | Delete with the SDK's default, or `k.checkpoints.forget(name)` and delete the snapshots it returns. | | The winner of `explore` is `""` | No branch exited 0 without error. | Look at each `ExploreBranch`'s `exit`, `output` and `error`. All branches were deleted unless `keep_all=True`. | | Sandboxes named `…-explore-NNNNN…` are left behind | The program stopped during `explore`, or `keep=` named an unknown branch (then every branch is kept). | Delete them; `ExploreResult.winner` is yours to delete too. | | `python3 -m venv` or `pip` fails with `ValueError: invalid literal for int() with base 10: ''` on macOS | Seen with Homebrew Python 3.14 in a restricted shell where `platform.mac_ver()` returned empty strings, so pip could not work out the macOS version. Not a problem in the `kosa8` package, which is pure Python. | Install with `uv` (`uv venv`, `uv pip install kosa8`), which does not run pip, or use another Python. `PYTHONPATH=src` on a checkout of the source also works: there is nothing to build. | **See also** [Python developer guide](https://docs.kosa8.com/guides/py-guide/) for how each call behaves; [API reference: errors](https://docs.kosa8.com/guides/py-api/#errors) for the constructors and `error_from_response`; [When something is wrong](https://docs.kosa8.com/guides/self-repair/) for `kosa8 doctor`. --- Source: https://docs.kosa8.com/guides/py-errors/ # Python API reference Every name the `kosa8` package exports in version 0.1.1 (its `__all__`), and the public members of each. Signatures are copied from the source of the published package. Keys in returned dicts are the daemon's own JSON names, unchanged. [`Kosa8Client`Every method, its endpoint and result.] [`Sandbox`The handle bound to one sandbox.] [`DataclassesExecResult`, `ExploreBranch`, `ExploreResult`.] [`Result shapes`The dicts methods return.] [`Transport`Endpoint rules and raw requests.] [`Checkpoint storesCheckpointBook` and the two stores.] [`Errors`The exception classes.] ## Package ``` from kosa8 import ( Kosa8Client, Sandbox, ExecResult, ExploreBranch, ExploreResult, Transport, default_socket_path, resolve_endpoint, CheckpointBook, FileCheckpointStore, MemoryCheckpointStore, MAX_CHECKPOINTS_PER_SANDBOX, Kosa8Error, Kosa8ConnectionError, Kosa8ApiError, Kosa8BadRequestError, Kosa8AuthError, Kosa8PolicyError, Kosa8ApprovalRequiredError, Kosa8NotFoundError, Kosa8ConflictError, Kosa8UnsupportedError, Kosa8StreamError, Kosa8CheckpointError, Kosa8TimeoutError, Kosa8ProtocolError, error_from_response, __version__, ) ``` | Name | Value | |---|---| | `__version__` | `"0.1.1"`. Changes from 0.1.0 are marked "0.1.1" below and listed in the package's `CHANGELOG.md`. | | `MAX_CHECKPOINTS_PER_SANDBOX` | `10`. Checkpoints kept per sandbox; the oldest is evicted past it. | Python 3.9 or later. No dependencies: only the standard library. The package ships `py.typed`, so type checkers read its annotations. Type aliases used below, from `kosa8.client` and `kosa8.transport`: ``` Env = Union[Mapping[str, str], Sequence[str], None] # {"A": "1"} or ["A=1"] Chunk = Callable[[bytes], None] # output callbacks EgressPolicy = Union[str, Mapping[str, Sequence[str]], None] # "deny" | "any" | "fakenet" | "allow:a,b" | {"allow": [...]} Body = Union[bytes, str, Iterable[Union[bytes, str]]] # write_stdin data Query = Mapping[str, Union[str, int, bool, None]] ``` ## `Kosa8Client` ``` Kosa8Client( *, socket_path: Optional[str] = None, host: Optional[str] = None, token: Optional[str] = None, insecure: bool = False, actor: str = "tool", agent_token: Optional[str] = None, headers: Optional[Mapping[str, str]] = None, timeout: Optional[float] = None, checkpoint_store: Any = None, env: Optional[Mapping[str, str]] = None, ) ``` All arguments are keyword-only. Constructing the client sends nothing; it resolves the endpoint (and can raise `Kosa8Error` for a refused address or a bad `actor`). | Parameter | Type | Default | Meaning | |---|---|---|---| | `socket_path` | `str` | `~/.kosa8/run/kosa8.sock` | The daemon's unix socket. Ignored when a host is set. | | `host` | `str` | `KOSA8_HOST` | A remote worker: `https://…`, `http://…`, `tcp://h:p`, `h:p`, or `unix:///path`. See [`resolve_endpoint`]. | | `token` | `str` | `KOSA8_TOKEN`, only when the host came from `KOSA8_HOST` | Bearer token, sent as `Authorization: Bearer …` to a TCP host only. | | `insecure` | `bool` | `False` (or `KOSA8_INSECURE=1`) | Allow plain `http` to a non-loopback host. | | `actor` | `str` | `"tool"` | `"human"`, `"agent"` or `"tool"`; sent as `X-Kosa8-Actor` and recorded in the audit log. Anything else raises `Kosa8Error`. | | `agent_token` | `str` | `KOSA8_AGENT_TOKEN` | Agent identity, sent as `X-Kosa8-Agent-Token`. | | `headers` | `Mapping[str, str]` | none | Extra headers on every request. The actor, approval, agent token and authorization headers are set after these and win. | | `timeout` | `float` | `None` (wait forever) | Socket timeout in seconds, applied to connect and to each read. Per-call `timeout=` overrides it. | | `checkpoint_store` | store | `FileCheckpointStore()` | Where checkpoint stacks are kept. Any object with `load()` and `update(fn)`. | | `env` | `Mapping[str, str]` | `os.environ` | Where `KOSA8_*` variables are read from. Pass `{}` to ignore the environment. | | Attribute | Type | Meaning | |---|---|---| | `transport` | `Transport` | The HTTP layer; `transport.target` says where requests go. | | `checkpoints` | `CheckpointBook` | The checkpoint stack logic over `checkpoint_store`; `checkpoints.store` is the store. | Every method that takes `approval: Optional[str] = None` sends it, stripped, as the `X-Kosa8-Approval` header. Methods without it in their signature send none. Every method can raise `Kosa8ConnectionError` (daemon unreachable), `Kosa8TimeoutError` (a timeout was set and passed), and the [HTTP error class] matching the daemon's status; only the extra errors are listed per method. Paths below are shortened: `…` is `/kosa8/v1`, and every path segment is percent-encoded. ### Status and the shared engine | Signature | Returns | HTTP | |---|---|---| | `health(*, approval=None) -> Dict[str, Any]` | `{status, version, commit, go, uptime_seconds, phase}` | `GET …/health` | | `version() -> str` | `health()["version"]`, such as `"0.9.36"` | `GET …/health` | | `pull(ref: str, *, approval=None) -> Dict[str, Any]` | The daemon's answer. Pulls into the host's shared engine, not a sandbox. | `POST …/pull`, body `{"ref"}` | ### Sandboxes | Signature | Returns | HTTP | |---|---|---| | `list_sandboxes() -> List[Dict[str, Any]]` | Sandbox records; `[]` when the daemon answers null. | `GET …/sandboxes` | | `get_sandbox(id_or_name: str) -> Optional[Dict[str, Any]]` | The record whose `id` matches, else whose `name` matches, else `None`. Never raises for a missing sandbox. | `GET …/sandboxes`, filtered in the client | | `create_sandbox(*, name=None, cpus=None, mem_mb=None, disk_gb=None, mounts=None, egress=None, egress_allow=None, capture=None, labels=None, broker=None, approval=None) -> Dict[str, Any]` | `{sandbox, boot_ms}`, plus `broker` when one was asked for | `POST …/sandboxes` | | `start_sandbox(sandbox: str, *, approval=None) -> Dict[str, Any]` | `{sandbox, boot_ms}` (and `warning` when the daemon has one) | `POST …/sandboxes/{id}/up` | | `stop_sandbox(sandbox: str, *, approval=None) -> Dict[str, Any]` | `{ok, sandbox}` (and `warning`, `already_stopped` when they apply) | `POST …/sandboxes/{id}/down` | | `delete_sandbox(sandbox: str, *, wipe: bool = False, keep_checkpoints: bool = False, approval=None) -> Dict[str, Any]` | The daemon's answer plus `checkpoints_dropped: int` | `DELETE …/sandboxes/{id}` (`?wipe=1` when `wipe`), then `DELETE …/snapshots/{ckpt}` per checkpoint | | `pull_into_sandbox(sandbox: str, ref: str, *, approval=None) -> Dict[str, Any]` | `{ok, image}` | `POST …/sandboxes/{id}/pull`, body `{"ref"}` | | `sandbox_images(sandbox: str) -> List[str]` | Image references in the sandbox's engine | `GET …/sandboxes/{id}/images` | | `sandbox_containers(sandbox: str) -> List[Dict[str, Any]]` | `[{id, image, status, exit?, ip?}]` | `GET …/sandboxes/{id}/containers` | | `sandbox_egress(sandbox: str) -> Dict[str, Any]` | `{policy, stats, …}`: the rule the host enforces and what it decided | `GET …/sandboxes/{id}/egress` | #### `create_sandbox` parameters A parameter that is `None`, empty or zero is left out of the request body, and the daemon applies its default. | Parameter | Type | Sent as | Meaning | |---|---|---|---| | `name` | `str` | `name` | The sandbox name. Omit it and the daemon picks one. | | `cpus` | `int` | `cpus` | Virtual CPUs. | | `mem_mb` | `int` | `mem_mb` | Memory in MiB. | | `disk_gb` | `int` | `disk_gb` | Disk size in GB. | | `mounts` | `List[Dict]` | `mounts` | Host folders: `{"host_path", "guest_path", "read_only"}`. | | `egress` | `EgressPolicy` | `egress` | `"deny"`, `"any"`, `"fakenet"`, `"allow:a.com,b.com"`, or `{"allow": [...]}` (sent as `"allow:…"`). `None`: the org policy's default. | | `egress_allow` | `Sequence[str]` | `egress: "allow:…"` | Shorthand for an allow-list. Combining it with an `egress` other than `None` or `"deny"` raises `Kosa8Error`. | | `capture` | `Dict` | `capture` | Packet capture: `{"path", "max_bytes"?, "max_files"?}`; `path` absolute, on the host. | | `labels` | `Mapping[str, str]` | `labels` | Free-form labels kept on the record. | | `broker` | `Dict` | `broker` | Egress broker spec, passed through: `{"allow"?: [{"host", "ports"?, "methods"?}], "routes"?: [{"name", "upstream", "header", "secret", "methods"?, "paths"?, "scheme"?}]}`. | | `approval` | `str` | header | A signed approval id. | Extra errors: `Kosa8Error` (client side) for `egress={"allow": []}` ("an egress allow-list needs at least one host…") and for `egress_allow` with another `egress`. ### Running code | Signature | Returns | HTTP | |---|---|---| | `run(sandbox: str, image: str, args: Sequence[str] = (), *, env: Env = None, on_stdout: Optional[Chunk] = None, on_stderr: Optional[Chunk] = None, on_status: Optional[Chunk] = None, timeout: Optional[float] = None, approval=None) -> ExecResult` | `ExecResult` | `POST …/sandboxes/{id}/run`, body `{"image", "args", "env"?}`, NDJSON answer | | `start_container(sandbox: str, image: str, args: Sequence[str] = (), *, approval=None) -> str` | The container id | `POST …/sandboxes/{id}/start`, body `{"image", "args"}` | | `exec(sandbox: str, container: str, args: Sequence[str], *, on_stdout=None, on_stderr=None, timeout=None, approval=None) -> ExecResult` | `ExecResult` | `POST …/sandboxes/{id}/exec`, body `{"container", "args"}` | | `exec_frames(sandbox: str, container: str, args: Sequence[str], *, timeout=None, approval=None) -> Iterator[Dict[str, Any]]` | A generator of frames (below). Nothing is sent until you start iterating. | `POST …/sandboxes/{id}/exec` | | `start_interactive(sandbox: str, image: str, args: Sequence[str] = (), *, env: Env = None, tty: bool = False, approval=None) -> str` | The container id | `POST …/sandboxes/{id}/start-interactive`, body `{"image", "args", "env", "tty"}` | | `write_stdin(sandbox: str, container: str, data: Body, *, approval=None) -> Dict[str, Any]` | `{ok}`. The body is the container's whole stdin: the daemon closes stdin when it ends. 0.1.1: a second call for the same `(sandbox, container)` strings on the same client raises `Kosa8Error` (`stdin of container … was already sent and is closed …`) without sending; 0.1.0 sent it and answered `{ok}` for input never read | `POST …/sandboxes/{id}/containers/{cid}/stdin`, `application/octet-stream`; an iterable is sent chunked | | `attach(sandbox: str, container: str, *, on_stdout=None, on_stderr=None, timeout=None, approval=None) -> ExecResult` | `ExecResult` when the container exits | `POST …/sandboxes/{id}/containers/{cid}/attach` | - `args` is an argv list; no shell is involved unless you run one. `env` is a mapping (sent as `["K=V", …]`) or a sequence of `"K=V"` strings. - Callbacks receive each chunk as `bytes`, in the calling thread, as it arrives. `on_status` (run only) receives the daemon's progress frames; status bytes are not collected into the result. - `timeout` is a socket timeout in seconds for connect and for each read, so a stream that keeps producing output within it can run longer in total. When it passes, the call raises `Kosa8TimeoutError`; the process in the sandbox keeps running. - A non-zero exit is not an error. Extra errors: `Kosa8StreamError` when the stream ends with an error frame, or with no final frame. A frame is a dict as the daemon sends it, with `data` base64-decoded to `bytes`: output frames `{"stream": "stdout" | "stderr" | "status", "data": b"…"}`, then a final `{"done": True, "ok": …, "exit": int}`, or `{"done": True, "error": "…"}`. `exec_frames` yields them unchanged and raises nothing for an error frame; an HTTP error before streaming starts is raised as its typed error. ### Snapshots, fork, explore | Signature | Returns | HTTP | |---|---|---| | `snapshot(sandbox: str, *, id: Optional[str] = None, encrypt: bool = False, approval=None) -> Dict[str, Any]` | `{snapshot, snapshot_ms}` (and `encrypted_by_policy`, `encrypt_ms`, `encryption` when encrypted) | `POST …/sandboxes/{id}/snapshot`, body `{"id"?, "encrypt"?: true}` | | `list_snapshots() -> List[Dict[str, Any]]` | Snapshot records | `GET …/snapshots` | | `restore_snapshot(snapshot_id: str, *, name: Optional[str] = None, label: Optional[str] = None, allow_tampered: bool = False, approval=None) -> Dict[str, Any]` | `{sandbox, restore_ms}` (and `seal` for a sealed snapshot) | `POST …/snapshots/{id}/restore`, body `{"name"?, "label"?, "allow_tampered"?: true}` | | `delete_snapshot(snapshot_id: str, *, wipe: bool = False, force: bool = False, approval=None) -> Dict[str, Any]` | `{ok, removed, encrypted, …}` | `DELETE …/snapshots/{id}`, `?wipe=1`, `?force=1` when set | | `fork(sandbox: str, n: int = 1, *, prefix: Optional[str] = None, label: Optional[str] = None, approval=None) -> Dict[str, Any]` | `{forks: [sandbox records], snapshot, fork_ms, count}` | `POST …/sandboxes/{id}/fork`, body `{"n", "prefix"?, "label"?}` | | `explore(sandbox: str, variants: Sequence[str], *, image: str = "alpine:latest", keep_all: bool = False, keep: Optional[str] = None, timeout: float = 120.0, prefix: Optional[str] = None, pull: bool = True, approval=None) -> ExploreResult` | `ExploreResult` | `pull_into_sandbox` (if `pull`), `fork`, one `run` per branch in parallel, then `DELETE …/sandboxes/{branch}` for the losers | #### `explore` parameters | Parameter | Default | Meaning | |---|---|---| | `variants` | (required) | Shell commands, one per branch, each run as `["sh", "-c", variant]`. At least two, or `Kosa8Error`. | | `image` | `"alpine:latest"` | The image each variant runs in. | | `keep_all` | `False` | Keep every branch instead of deleting the losers. | | `keep` | `None` | Keep this branch name as the winner. Not one of the branches: `Kosa8Error`, and every branch is left in place. | | `timeout` | `120.0` | Seconds, per branch, passed to `run` as its socket timeout. | | `prefix` | `f"{sandbox}-explore-{int(time.time()) % 100000}"` | Branch name prefix. | | `pull` | `True` | Pull `image` into the parent first. | Branches run on a `ThreadPoolExecutor` with one worker per variant. A branch whose error message looks transient (connection refused, EOF, reset, broken pipe, "no such file or directory", "stream ended", unreachable) is retried up to four attempts, waiting 0.5, 1 and 1.5 seconds between them (0.1.0 also waited 2 seconds after the last); a timeout is never retried. The winner is the fastest branch with no error and exit 0; `""` when none succeeded. A failed delete of a loser is not raised; `removed` counts the ones that went. Extra errors: `Kosa8Error` when the fork returned fewer copies than variants. ### Checkpoints Client-side, over snapshots. The daemon has no checkpoint endpoint. | Signature | Returns | HTTP | |---|---|---| | `checkpoint(sandbox: str, *, label: str = "", approval=None) -> Dict[str, Any]` | `{checkpoint, evicted: List[str], snapshot}`: the stored record, the ids evicted, and the `snapshot()` answer | `POST …/sandboxes/{id}/snapshot` with id `-ckpt-`, then `DELETE …/snapshots/{old}` per eviction | | `list_checkpoints(sandbox: str) -> List[Dict[str, Any]]` | Records `{id, sandbox, label, created}`, oldest first | none: the store | | `rewind(sandbox: str, *, to: Optional[str] = None, approval=None) -> Dict[str, Any]` | `{sandbox, checkpoint, dropped: List[str], restore_ms}` | `DELETE …/sandboxes/{id}`, `POST …/snapshots/{ckpt}/restore` with `{"name": sandbox}`, then `DELETE …/snapshots/{later}` per dropped checkpoint | `to` is a checkpoint id or a label (case-insensitive; the newest match wins); without it, the latest. Extra errors: `Kosa8CheckpointError` for no checkpoints, no match, or an invalid sandbox name (empty, `.`, `..`, containing `/` or `\`, or starting with a dot). If the restore fails after the delete, `rewind` raises `Kosa8CheckpointError` (0.1.1; a plain `Kosa8Error` saying "is now stopped" in 0.1.0) with the message `restore failed and NAME no longer exists (it was deleted to free its name for the restore): … — the checkpoint is intact; recover with restore_snapshot('ID', name='NAME')`, chained to the daemon's error. `checkpoint` (0.1.1): when the snapshot is refused with a 409 whose message contains "already exists" (a concurrent checkpoint took the id), it tries the next number, up to ten attempts in all; any other 409, or the tenth, is raised. Failures deleting evicted or dropped snapshots are not raised. ### Helper | Signature | Returns | |---|---| | `sandbox(sandbox_id: str) -> Sandbox` | A handle bound to one sandbox. Sends nothing. | ## `Sandbox` ``` Sandbox(client: Kosa8Client, sandbox_id: str) client: Kosa8Client id: str repr(sb) # "Sandbox('doc-py-guide')" ``` Each method calls the client method with the sandbox filled in, passing other keyword arguments through (`**kw`), so the options and errors are the client's. | Method | Calls | |---|---| | `info() -> Optional[Dict]` | `get_sandbox(id)` | | `start(**kw) -> Dict` | `start_sandbox(id, **kw)` | | `stop(**kw) -> Dict` | `stop_sandbox(id, **kw)` | | `delete(**kw) -> Dict` | `delete_sandbox(id, **kw)` | | `pull(ref, **kw) -> Dict` | `pull_into_sandbox(id, ref, **kw)` | | `run(image, args=(), **kw) -> ExecResult` | `run(id, image, args, **kw)` | | `start_container(image, args=(), **kw) -> str` | `start_container(id, image, args, **kw)` | | `exec(container, args, **kw) -> ExecResult` | `exec(id, container, args, **kw)` | | `containers() -> List[Dict]` | `sandbox_containers(id)` | | `images() -> List[str]` | `sandbox_images(id)` | | `egress() -> Dict` | `sandbox_egress(id)` | | `snapshot(**kw) -> Dict` | `snapshot(id, **kw)` | | `fork(n=1, **kw) -> Dict` | `fork(id, n, **kw)` | | `explore(variants, **kw) -> ExploreResult` | `explore(id, variants, **kw)` | | `checkpoint(**kw) -> Dict` | `checkpoint(id, **kw)` | | `checkpoints() -> List[Dict]` | `list_checkpoints(id)` | | `rewind(**kw) -> Dict` | `rewind(id, **kw)` | | `exec_frames(container, args, **kw) -> Iterator[Dict]` (0.1.1) | `exec_frames(id, container, args, **kw)` | | `start_interactive(image, args=(), **kw) -> str` (0.1.1) | `start_interactive(id, image, args, **kw)` | | `write_stdin(container, data, **kw) -> Dict` (0.1.1) | `write_stdin(id, container, data, **kw)` | | `attach(container, **kw) -> ExecResult` (0.1.1) | `attach(id, container, **kw)` | Not on the handle: `restore_snapshot`, `delete_snapshot`, `list_snapshots`, and the host-engine `pull`. Call them on the client. (In 0.1.0, `exec_frames`, `start_interactive`, `write_stdin` and `attach` were missing from the handle too.) ## Dataclasses ### `ExecResult` ``` @dataclass class ExecResult: exit_code: int stdout_bytes: bytes = b"" stderr_bytes: bytes = b"" stdout: str # property: stdout_bytes decoded as UTF-8, errors replaced stderr: str # property: the same for stderr_bytes ok: bool # property: exit_code == 0 ``` When the final frame carries no integer `exit`, `exit_code` is `0`. ### `ExploreBranch` ``` @dataclass class ExploreBranch: name: str # the branch sandbox's id variant: str # the shell command it ran exit: int = 0 output: str = "" # stdout + stderr, trailing newlines stripped duration_ms: int = 0 # including retries error: Optional[str] = None # the last error, if the run never finished ``` ### `ExploreResult` ``` @dataclass class ExploreResult: parent: str branches: List[ExploreBranch] = field(default_factory=list) winner: str = "" # "" when no branch succeeded removed: int = 0 # branches deleted ``` ## Result shapes Methods other than the three above return the daemon's JSON as plain dicts and lists. The keys you will meet most, as kosa8 0.9.36 sends them: | Shape | Keys | |---|---| | sandbox record | `id, name, created, state` (running, paused, stopped), `cpus, mem_mb`, and when set `disk_gb, parent, mounts, egress, capture, ip, labels, broker` | | snapshot record | `id, sandbox, created, cpus, mem_mb, cmdline, size_bytes, state_path`, and optional fields such as `disk_gb, mounts, egress, sealed, labels, encryption` | | checkpoint record | `id, sandbox, label, created` (RFC 3339, UTC). Records written by the Go tools may also carry `auto`. | | container | `id, image, status`, and `exit, ip` when known | | egress status | `policy, stats: {passed, denied, dropped, dns_answered, dns_refused, …}`, and `capture, wildcards, fakenet` when they apply | ## Transport and endpoints ### `Transport` ``` Transport( *, socket_path: Optional[str] = None, host: Optional[str] = None, token: Optional[str] = None, insecure: bool = False, actor: str = "tool", agent_token: Optional[str] = None, headers: Optional[Mapping[str, str]] = None, timeout: Optional[float] = None, env: Optional[Mapping[str, str]] = None, ) ``` The same connection parameters as `Kosa8Client`, without `checkpoint_store`. It opens a new connection per request. | Member | Meaning | |---|---| | `endpoint` | The resolved `Endpoint`. | | `actor`, `agent_token`, `extra_headers`, `timeout` | As given (`agent_token` falls back to `KOSA8_AGENT_TOKEN`). | | `target -> str` | Property: `unix:///path` or `scheme://host[:port]`. | | `raw(method, path, *, query=None, body=None, raw_body=None, approval=None, headers=None, timeout=None) -> Response` | Sends a request and returns once the headers arrive. `body` is JSON-encoded; `raw_body` (bytes, str or an iterable of them) is sent as `application/octet-stream`, chunked for an iterable. In `query`, `None` and `False` are dropped and `True` becomes `"1"`. Does not raise for HTTP errors. Raises `Kosa8TimeoutError` or `Kosa8ConnectionError`. | | `json(method, path, **kw) -> Any` | `raw`, then the body decoded as JSON (`None` for an empty body, the text if it is not JSON). A status of 400 or more raises `error_from_response(…)`. | | `frames(method, path, **kw) -> Iterator[Dict]` | `raw`, then one decoded frame per NDJSON line, `data` base64-decoded. An HTTP error before streaming raises the typed error; a read timeout mid-stream raises `Kosa8TimeoutError`; a line that is not valid JSON, or whose `data` is not valid base64, raises `Kosa8ProtocolError` (0.1.1; 0.1.0 let the decoding error escape). | `Response` (returned by `raw`, in `kosa8.transport`) has `status`, `headers`, `read() -> bytes`, `lines() -> Iterator[bytes]` and `close()`, and is a context manager; `read` and `lines` close it when done. ### `resolve_endpoint` ``` resolve_endpoint( host: Optional[str] = None, socket_path: Optional[str] = None, token: Optional[str] = None, insecure: bool = False, env: Optional[Mapping[str, str]] = None, ) -> Endpoint ``` Where to connect, by the kosa8 CLI's rules: 1. The value is `host` if given (even `""`), else `KOSA8_HOST`, stripped of spaces and trailing slashes. Empty: the unix socket at `socket_path` or `default_socket_path()`. 2. The scheme is lower-cased. `unix:///path` is a socket. `tcp://` is read as `http://`. No scheme (`host:port`) is read as `http://`. Any other scheme raises `Kosa8Error` (`KOSA8_HOST scheme "ftp" is not supported; use http://, https://, tcp:// or unix://`). No host name raises `Kosa8Error`. 3. Plain `http` to a host other than `localhost`, `::1` or `127.*` raises `Kosa8Error` (`refusing to talk to … over plain http …`) unless `insecure` or `KOSA8_INSECURE=1`. 4. The token is `token` if given; otherwise `KOSA8_TOKEN`, but only when the host came from `KOSA8_HOST` (never with an explicit `host`). A path in the URL is kept as a prefix for every request. `Endpoint` (in `kosa8.transport`) is a frozen dataclass: `kind` (`"unix"` or `"tcp"`), `socket_path`, `scheme`, `host`, `port`, `base_path`, `token`, and the property `target`. ### Other transport names | Name | Meaning | |---|---| | `default_socket_path() -> str` | `os.path.join(os.path.expanduser("~"), ".kosa8", "run", "kosa8.sock")` | | `kosa8.transport.APPROVAL_HEADER` | `"X-Kosa8-Approval"` | | `kosa8.transport.ACTOR_HEADER` | `"X-Kosa8-Actor"` | | `kosa8.transport.AGENT_TOKEN_HEADER` | `"X-Kosa8-Agent-Token"` | | `kosa8.transport.decode_frame(line) -> Dict` | One NDJSON frame with `data` base64-decoded. | | `kosa8.transport.enc(segment) -> str` | Percent-encodes one path segment (nothing is safe, so `/` becomes `%2F`). | These six live in `kosa8.transport` and are not re-exported from `kosa8`. ## Checkpoint stores | Name | Signature and members | |---|---| | `FileCheckpointStore` | `FileCheckpointStore(path: Optional[str] = None, *, lock_timeout: float = 10.0)` (`lock_timeout` is 0.1.1). Attributes `path`, `lock_timeout`. `path` defaults to `~/.kosa8/checkpoints.json`, the file the CLI, MCP server and desk use. `load() -> Dict[str, List[Dict]]` (`{}` if missing or corrupt). `update(fn) -> T`: takes an in-process lock and an exclusive `fcntl.flock` on `.lock` (the same lock the Go tools take; in-process only where `fcntl` is missing). 0.1.1: the `flock` is tried without blocking every 50 ms, and after `lock_timeout` seconds raises `Kosa8CheckpointError` (`timed out after 10s waiting for PATH.lock; another kosa8 process is holding it`); 0.1.0 blocked indefinitely, loads, calls `fn(data)` to change it in place, writes `.tmp` with mode 0600 and renames it over the file. Creates the directory with mode 0700. | | `MemoryCheckpointStore` | `MemoryCheckpointStore()`. `load()` returns a deep copy; `update(fn)` runs `fn` on a copy under a lock and keeps it. Nothing is shared with the CLI. | | `CheckpointBook` | `CheckpointBook(store)`, available as `client.checkpoints`. `store`; `next_id(sandbox) -> Tuple[str, List[str]]` (the next `-ckpt-`, with `n` one past the larger of the stack size and the highest number used, and the ids to evict); `record(cid, sandbox, label="", evicted=None) -> Dict`; `list(sandbox) -> List[Dict]` (sorted by `created`); `latest(sandbox) -> Dict`; `resolve(sandbox, ref) -> Dict`; `pop(sandbox, cid) -> List[str]` (keeps `cid` on top, returns the later ids); `forget(sandbox) -> List[str]` (drops the stack, returns its ids). Raises `Kosa8CheckpointError`. | The store data is `{sandbox_name: [checkpoint record, …]}`. Any object with `load()` and `update(fn)` of these shapes can be passed as `checkpoint_store`. `kosa8.checkpoints.validate_sandbox_name(name)` is the name check used by `next_id` and `record`. ## Errors | Class | Base | Raised for | |---|---|---| | `Kosa8Error` | `Exception` | Base class; client-side checks. | | `Kosa8ConnectionError` | `Kosa8Error` | Daemon unreachable (any `OSError` while sending). | | `Kosa8TimeoutError` | `Kosa8Error` | A socket timeout passed. | | `Kosa8ProtocolError` (0.1.1) | `Kosa8Error` | A streamed frame that is not valid NDJSON (bad JSON or base64). `method`, `path` set; the decoding error is `__cause__`. | | `Kosa8StreamError` | `Kosa8Error` | Error frame, or no final frame. `stdout`, `stderr`. | | `Kosa8CheckpointError` | `Kosa8Error` | Checkpoint bookkeeping; from 0.1.1 also a checkpoint lock timeout and a rewind whose restore failed. | | `Kosa8ApiError` | `Kosa8Error` | Any HTTP error status without a more specific class (500, 503, …). | | `Kosa8BadRequestError` | `Kosa8ApiError` | 400 | | `Kosa8AuthError` | `Kosa8ApiError` | 401 | | `Kosa8PolicyError` | `Kosa8ApiError` | 403 | | `Kosa8ApprovalRequiredError` | `Kosa8PolicyError` | 403 whose body's `approval` is a dict with a string `why`. `approval: dict`. | | `Kosa8NotFoundError` | `Kosa8ApiError` | 404 | | `Kosa8ConflictError` | `Kosa8ApiError` | 409 | | `Kosa8UnsupportedError` | `Kosa8ApiError` | 501 | Every class has the attributes `message`, `status`, `daemon_message`, `remediation`, `method`, `path` and `body` (each `None` when not known). The constructor is `Kosa8Error(message, *, status=None, daemon_message=None, remediation=None, method=None, path=None, body=None)`; `Kosa8ApprovalRequiredError(message, approval, **kw)`; `Kosa8StreamError(message, stdout="", stderr="", **kw)`. ``` error_from_response(status: int, body: Any, *, method: Optional[str] = None, path: Optional[str] = None) -> Kosa8ApiError ``` Builds the typed error for an HTTP error answer, as the client does. `daemon_message` is the body's `error`, or else its `message`, or a non-empty text body; `remediation` is the body's `remediation`. The message is `" ()"`, or the daemon message alone, or `"request failed with HTTP "`. Details and real messages: [Python errors and troubleshooting](https://docs.kosa8.com/guides/py-errors/). --- Source: https://docs.kosa8.com/guides/py-api/ # Go quickstart One `main.go`, five minutes. You will connect to kosa8 from Go, start a sealed sandbox with no network, run a command in it, save it, break it, put it back, and delete it. **How this page was tested** The program on this page was run against a real kosa8 daemon 0.9.36 on an Apple silicon Mac (macOS 26.0.1), with `github.com/kosa8ai/kosa8-go v0.1.2` fetched from the Go module proxy and built with go1.26.9 darwin/arm64. The output shown is what that run printed. Timings are from that run, on a machine that was busy with other sandboxes at the time, and will differ on yours. ## What this is, in plain words A **sandbox** is a small, separate computer that runs inside your computer. It has its own operating system, its own files and its own network settings. Code running inside it cannot see your files, your passwords or your network unless you allow it. If the code does something bad, the damage stays inside the sandbox, and you can throw the sandbox away. That matters when code comes from somewhere you do not fully trust: an AI agent writing and running its own commands, a user's script on your server, a package you have not reviewed. kosa8 runs that code in a sandbox on your own machine, not in someone else's cloud. The **Go SDK** (`kosa8-go`) lets a Go program do all of this: create sandboxes, run commands in them, read the output, save a sandbox's exact state and go back to it later. It talks to the kosa8 background service (the daemon) that runs on your machine. It uses only Go's standard library, so it adds no other dependencies to your project. ## What you need | Part | Requirement | |---|---| | kosa8 | Installed, with its daemon running. See [Install](https://docs.kosa8.com/guides/install/). `kosa8 version` shows both parts. | | Machine | macOS on Apple silicon, x86-64 Linux with KVM, or Windows 11 on x86-64 (run your program inside WSL2). Sandboxes are real virtual machines. | | Go | 1.21 or later (the module's `go` line is 1.21). | | Disk | Room for the guest image the daemon downloads on first use (about 1.5 GB), plus sandboxes and checkpoints. | ``` $ kosa8 version kosa8 client 0.9.36 (f6124c0, darwin/arm64) kosa8 daemon 0.9.36 (up 3417s) ``` If the second line says the daemon is not running, start it with `kosa8 daemon start` (the Mac app starts it for you). ## Install the module ``` $ mkdir hello-kosa8 && cd hello-kosa8 $ go mod init hello-kosa8 $ go get github.com/kosa8ai/kosa8-go@v0.1.2 go: downloading github.com/kosa8ai/kosa8-go v0.1.2 go: added github.com/kosa8ai/kosa8-go v0.1.2 ``` The package name is `kosa8`. Import it as `kosa8 "github.com/kosa8ai/kosa8-go"`. ## The walkthrough The steps below are parts of one file, `main.go`. The whole file, and what it printed, is at the end. All the work happens in a `run` function that returns an error, so that the clean-up in step 2 always runs, even when a later step fails. ### Connect ``` // 1. Connect. With empty Options the client uses ~/.kosa8/run/kosa8.sock. k, err := kosa8.New(kosa8.Options{}) if err != nil { return err } v, err := k.Version(ctx) if err != nil { return err } fmt.Println("kosa8", v, "at", k.Endpoint().Target()) ``` ``` kosa8 0.9.36 at unix:///Users/dhruvit/.kosa8/run/kosa8.sock ``` `kosa8.New` only works out where to connect; nothing is sent until the first call. `Version` asks the daemon for its version, which proves the connection works. If the daemon is not running, this is where you find out: the error says so, and says how to start it. ### Create a sandbox with no network ``` // 2. Create a sandbox: a microVM with its own kernel and no network at all. created, err := k.CreateSandbox(ctx, kosa8.CreateOptions{Name: "doc-go-demo", Egress: "deny"}) if err != nil { return err } sb := k.Sandbox("doc-go-demo") // Always clean up, even if a later step fails. defer func() { out, err := sb.Delete(ctx, kosa8.DeleteOptions{}) fmt.Println("deleted:", out, err) }() info := created["sandbox"].(map[string]any) fmt.Printf("sandbox %s: %s, egress %s, booted in %vms\n", info["name"], info["state"], info["egress"], created["boot_ms"]) ``` ``` sandbox doc-go-demo: running, egress deny, booted in 11965ms ``` `Egress: "deny"` closes the network completely. The rule is enforced on your computer, outside the sandbox, so code inside cannot turn it off. `k.Sandbox(name)` returns a handle with the sandbox's name filled in, so later calls are shorter. The answer is the daemon's JSON as a Go map (`kosa8.Info`). Booting took 12 seconds here because the machine was busy; on an idle Mac it is usually a second or two. ### Pull an image and run a command ``` // 3. Pull an image into the sandbox (fetched on the host), then run a command to completion. if _, err := sb.Pull(ctx, "alpine:latest"); err != nil { return err } res, err := sb.Run(ctx, "alpine:latest", []string{"sh", "-c", "echo kernel $(uname -r); wget -q -T 3 -O- http://example.com || echo no network; exit 3"}, nil) if err != nil { return err } fmt.Printf("exit %d\n%s", res.ExitCode, res.Stdout()) ``` ``` exit 3 kernel 6.18.35 no network ``` Code in a sandbox runs in containers on the sandbox's own engine, so it needs an image. `Pull` fetches it on the host and copies it in, which works even though the sandbox has no network. `Run` starts a fresh container, waits for it to finish and returns its output. The kernel is the sandbox's own, and the download failed, as it should. A command that exits with a non-zero code is a *result*, not an error: `err` is nil and `res.ExitCode` is 3. `err` is set only when the command could not run at all. ### Keep a container running ``` // 4. Keep a container running, and write a file in it. cid, err := sb.StartContainer(ctx, "alpine:latest", []string{"sh", "-c", "while :; do sleep 3600; done"}) if err != nil { return err } sh := func(cmd string) string { r, err := sb.Exec(ctx, cid, []string{"sh", "-c", cmd}, nil) if err != nil { return "error: " + err.Error() } return strings.TrimSpace(r.Stdout() + r.Stderr()) } fmt.Println("wrote /note:", sh("echo v1 > /note; cat /note")) ``` ``` wrote /note: v1 ``` `Run` starts from a clean image every time. To keep files between commands, start a container that stays up and `Exec` commands in it. ### Checkpoint, break something, rewind ``` // 5. Checkpoint, break something, rewind. cp, err := sb.Checkpoint(ctx, "before the risky bit") if err != nil { return err } fmt.Printf("checkpoint %s (%s) in %vms\n", cp.Checkpoint.ID, cp.Checkpoint.Label, cp.Snapshot["snapshot_ms"]) sh("rm /note") fmt.Println("after rm:", sh("cat /note")) rw, err := sb.Rewind(ctx, "") if err != nil { return err } fmt.Printf("rewound to %s in %vms\n", rw.Checkpoint.ID, rw.RestoreMS) fmt.Println("after rewind: /note =", sh("cat /note")) return nil ``` ``` checkpoint doc-go-demo-ckpt-1 (before the risky bit) in 908ms after rm: cat: can't open '/note': No such file or directory rewound to doc-go-demo-ckpt-1 in 487ms after rewind: /note = v1 deleted: map[checkpoints_dropped:1 ok:true] ``` A checkpoint saves the whole running sandbox: memory, running programs and disks. `Rewind` with `""` goes back to the latest one. The sandbox returns under the same name with the same container still running, so the same container id works and the deleted file is back. The last line is the deferred `Delete` from step 2: it removed the sandbox and its one checkpoint. ## The whole file ``` package main import ( "context" "fmt" "os" "strings" kosa8 "github.com/kosa8ai/kosa8-go" ) func main() { if err := run(context.Background()); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } } func run(ctx context.Context) error { // 1. Connect. With empty Options the client uses ~/.kosa8/run/kosa8.sock. k, err := kosa8.New(kosa8.Options{}) if err != nil { return err } v, err := k.Version(ctx) if err != nil { return err } fmt.Println("kosa8", v, "at", k.Endpoint().Target()) // 2. Create a sandbox: a microVM with its own kernel and no network at all. created, err := k.CreateSandbox(ctx, kosa8.CreateOptions{Name: "doc-go-demo", Egress: "deny"}) if err != nil { return err } sb := k.Sandbox("doc-go-demo") // Always clean up, even if a later step fails. defer func() { out, err := sb.Delete(ctx, kosa8.DeleteOptions{}) fmt.Println("deleted:", out, err) }() info := created["sandbox"].(map[string]any) fmt.Printf("sandbox %s: %s, egress %s, booted in %vms\n", info["name"], info["state"], info["egress"], created["boot_ms"]) // 3. Pull an image into the sandbox (fetched on the host), then run a command to completion. if _, err := sb.Pull(ctx, "alpine:latest"); err != nil { return err } res, err := sb.Run(ctx, "alpine:latest", []string{"sh", "-c", "echo kernel $(uname -r); wget -q -T 3 -O- http://example.com || echo no network; exit 3"}, nil) if err != nil { return err } fmt.Printf("exit %d\n%s", res.ExitCode, res.Stdout()) // 4. Keep a container running, and write a file in it. cid, err := sb.StartContainer(ctx, "alpine:latest", []string{"sh", "-c", "while :; do sleep 3600; done"}) if err != nil { return err } sh := func(cmd string) string { r, err := sb.Exec(ctx, cid, []string{"sh", "-c", cmd}, nil) if err != nil { return "error: " + err.Error() } return strings.TrimSpace(r.Stdout() + r.Stderr()) } fmt.Println("wrote /note:", sh("echo v1 > /note; cat /note")) // 5. Checkpoint, break something, rewind. cp, err := sb.Checkpoint(ctx, "before the risky bit") if err != nil { return err } fmt.Printf("checkpoint %s (%s) in %vms\n", cp.Checkpoint.ID, cp.Checkpoint.Label, cp.Snapshot["snapshot_ms"]) sh("rm /note") fmt.Println("after rm:", sh("cat /note")) rw, err := sb.Rewind(ctx, "") if err != nil { return err } fmt.Printf("rewound to %s in %vms\n", rw.Checkpoint.ID, rw.RestoreMS) fmt.Println("after rewind: /note =", sh("cat /note")) return nil } ``` ``` $ go run . kosa8 0.9.36 at unix:///Users/dhruvit/.kosa8/run/kosa8.sock sandbox doc-go-demo: running, egress deny, booted in 11965ms exit 3 kernel 6.18.35 no network wrote /note: v1 checkpoint doc-go-demo-ckpt-1 (before the risky bit) in 908ms after rm: cat: can't open '/note': No such file or directory rewound to doc-go-demo-ckpt-1 in 487ms after rewind: /note = v1 deleted: map[checkpoints_dropped:1 ok:true] ``` ## Look at it from the command line The SDK and the CLI drive the same daemon and share the same checkpoint list, so you can watch what your program does, or finish a job by hand: ``` $ kosa8 sandbox ls $ kosa8 checkpoint ls doc-go-demo $ kosa8 audit ls -n 10 # your program's calls, recorded as actor "tool" ``` **If the rewind fails on a Mac** Restoring a saved machine needs the screen to be unlocked. The checkpoint succeeds and the rewind fails with `macOS blocks restoring a VM while the screen is locked; unlock and retry`. By then the rewind has already removed the sandbox; the checkpoint's snapshot is intact, and the error message ends with the `RestoreSnapshot(…)` call that brings it back. See [Go errors and troubleshooting](https://docs.kosa8.com/guides/go-errors/). **Next** [Go developer guide](https://docs.kosa8.com/guides/go-guide/): every option and capability in detail. [Errors and troubleshooting](https://docs.kosa8.com/guides/go-errors/) and the [API reference](https://docs.kosa8.com/guides/go-api/) list everything else. --- Source: https://docs.kosa8.com/guides/go-quickstart/ # Go developer guide The [quickstart](https://docs.kosa8.com/guides/go-quickstart/) shows one happy path. This page covers everything else `github.com/kosa8ai/kosa8-go` v0.1.2 does, how it does it, and where it surprises you. Each section starts in plain words and then goes into detail. Exact signatures are in the [API reference](https://docs.kosa8.com/guides/go-api/); every error is in [Errors and troubleshooting](https://docs.kosa8.com/guides/go-errors/). **How this page was tested** Every program on this page was run against a real kosa8 daemon 0.9.36 on an Apple silicon Mac with 16 GB of memory, with `github.com/kosa8ai/kosa8-go v0.1.2` fetched from the Go module proxy and built with go1.26.9 darwin/arm64. The programs were first run on v0.1.0; v0.1.1 and v0.1.2 fixed bugs found that way (see [Versions]). Every program was run again on v0.1.1; the sandbox-free error checks and the mount and capture program were run again on v0.1.2, which changed no behaviour the other programs touch. Where an older version behaves differently, the page says so. Long lines are cut only where marked with `…`; long lines are cut only where marked with `…`. Other sandboxes were running on the same machine at the time, so timings are slow and will differ on yours, and several attempts to create a sandbox failed with `guest not ready on vsock port 1024 after 25s` while memory was short (see [the errors page](https://docs.kosa8.com/guides/go-errors/#guest-not-ready)); the retries succeeded. Every sandbox, snapshot and capture file the programs created was deleted. Not tested here: a remote worker (`KOSA8_HOST`), approvals, an org policy, `Broker`, `Encrypt`, `Wipe` and `TTY`. Those are described from the source, and say so. ## What this is, in plain words kosa8 runs code inside **sandboxes**: small, separate virtual computers on your own machine. Code inside one cannot reach your files, passwords or network unless you allow it. A background program called the **daemon** creates and manages them. The Go SDK is a library your Go program uses to give the daemon instructions: make a sandbox, run this command in it, save it, put it back, throw it away. A few words appear all over this page: | Word | In plain words | For developers | |---|---|---| | Sandbox | A sealed computer inside yours. | A microVM with its own kernel, disk and private container engine. | | Image | A packaged set of programs, like "Alpine Linux" or "Python 3.12". | An OCI image. It must be pulled *into* the sandbox before use. | | Container | One program running from an image, inside the sandbox. | A container on the sandbox's engine; ids look like `k4-c858e2e1`. | | Egress | Which websites the sandbox may talk to. | A host-enforced network policy: `deny`, `any`, `fakenet`, `allow:h1,h2`. | | Checkpoint | An "undo point": the whole sandbox saved, so you can go back. | A snapshot named `-ckpt-` plus a record in `~/.kosa8/checkpoints.json`. | | Rewind | Go back to an undo point. | Delete the sandbox, restore the snapshot under the same name, processes resumed. | | Fork | Make copies of a running sandbox, to try different things in each. | Snapshot once, restore N times as `1…N`. | | Explore | Try several ideas at once and keep whichever worked fastest. | Fork per variant, run `sh -c VARIANT` in each concurrently, keep the fastest exit-0 branch. | ## How the SDK is built In plain words: one object, the **client**, holds the connection details. You ask it to do things, and it sends each request to the daemon and turns the answer into Go values. In detail: - **Standard library only.** `go.mod` has no `require` lines. Requests are plain HTTP/1.1 over the daemon's unix socket (or to a remote worker), to the native API under `/kosa8/v1` (see [Native HTTP API](https://docs.kosa8.com/guides/native-api/)). - **`kosa8.New` sends nothing.** It resolves the endpoint, checks `Actor`, builds an `http.Client` and picks a checkpoint store. A wrong socket path is only noticed on the first call. - **Answers are maps.** Most methods return `kosa8.Info`, which is `map[string]any`: the daemon's JSON, untouched. Numbers are `float64`, nested objects are `map[string]any`. Only the process, fork, explore, checkpoint and rewind results are typed structs. Use the comma-ok form when you read a field: `s, _ := created["sandbox"].(map[string]any)`. - **Two ways to address a sandbox.** Every method exists on `*Client` with the sandbox id as its second argument. `k.Sandbox(id)` returns a `*Sandbox` handle with the id filled in. The handle is just `{client, ID}`; making one sends nothing and does not check that the sandbox exists. It has no `ExecFrames`, `StartInteractive`, `WriteStdin`, `Attach`, `RestoreSnapshot` or `DeleteSnapshot`; call those on the client with `sb.ID`. - **Approvals ride along per call.** Methods without an options struct take a trailing `call ...kosa8.Call`; methods with one have an `Approval` field. Either way it becomes the `X-Kosa8-Approval` header. ## Connecting In plain words: by default the SDK talks to the kosa8 running on this machine, and you do not have to configure anything. You only set options to reach a kosa8 on another machine, or to label who is making the calls. ### Where requests go `kosa8.ResolveEndpoint` is the function `New` uses, and it is exported, so you can see the decision without sending anything. This program prints the endpoint for several inputs, using a fake environment: ``` env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } } for _, c := range []struct { host string env map[string]string }{ {"", nil}, {"", map[string]string{"KOSA8_HOST": "https://worker.example.com/", "KOSA8_TOKEN": "t0k"}}, {"https://worker.example.com", map[string]string{"KOSA8_TOKEN": "t0k"}}, {"tcp://127.0.0.1:7878", nil}, {"10.0.0.5:7878", nil}, {"10.0.0.5:7878", map[string]string{"KOSA8_INSECURE": "1"}}, {"unix:///tmp/k8/kosa8.sock", nil}, {"ftp://x", nil}, } { ep, err := kosa8.ResolveEndpoint(c.host, "", "", false, env(c.env)) if err != nil { fmt.Printf("%-28q -> error: %v\n", c.host, err) continue } fmt.Printf("%-28q -> %s token=%q\n", c.host, ep.Target(), ep.Token) } _, err = kosa8.New(kosa8.Options{Actor: "robot"}) fmt.Println("bad actor:", err) ``` ``` "" -> unix:///Users/dhruvit/.kosa8/run/kosa8.sock token="" "" -> https://worker.example.com token="t0k" "https://worker.example.com" -> https://worker.example.com token="" "tcp://127.0.0.1:7878" -> http://127.0.0.1:7878 token="" "10.0.0.5:7878" -> error: refusing to talk to 10.0.0.5:7878 over plain http: the token and every request would cross the network unencrypted. Use https://10.0.0.5:7878 (a worker behind a TLS-terminating tunnel or proxy), or set Insecure / KOSA8_INSECURE=1 if this link is already private and trusted "10.0.0.5:7878" -> http://10.0.0.5:7878 token="" "unix:///tmp/k8/kosa8.sock" -> unix:///tmp/k8/kosa8.sock token="" "ftp://x" -> error: KOSA8_HOST scheme "ftp" is not supported; use http://, https://, tcp:// or unix:// bad actor: actor must be human, agent or tool, not "robot" ``` Read the third line carefully: **when you set `Options.Host` in code, `KOSA8_TOKEN` from the environment is ignored.** The env token is only paired with an env host. If you pass `Host`, pass `Token` too (`Token: os.Getenv("KOSA8_TOKEN")` if that is where it lives). Otherwise the worker answers 401 (`ErrAuth`). ### Environment variables | Variable | Read by | Effect | |---|---|---| | `KOSA8_HOST` | `New`, when `Options.Host` is empty | Selects a remote worker. Empty: the local socket. | | `KOSA8_TOKEN` | `New`, only when the host came from `KOSA8_HOST` and `Options.Token` is empty | Sent as `Authorization: Bearer …`. Never sent over a unix socket. | | `KOSA8_INSECURE` | `New` | `1` allows plain `http://` to a host that is not `localhost`, `::1` or `127.*`. | | `KOSA8_AGENT_TOKEN` | `New`, when `Options.AgentToken` is empty | Sent as `X-Kosa8-Agent-Token`; see [Agent identity](https://docs.kosa8.com/guides/agent-identity/). | | `HOME` | `DefaultSocketPath`, `NewFileCheckpointStore("")` | The socket is `$HOME/.kosa8/run/kosa8.sock`; checkpoints live in `$HOME/.kosa8/checkpoints.json`. | All of them are read once, in `New`, through `Options.Getenv` (default `os.Getenv`). Changing the environment later does not affect an existing client. In tests, pass `Getenv` to keep the real environment out. ### Who is calling: Actor, AgentToken, Headers `Actor` is `"tool"` unless you say `"human"` or `"agent"`, and is sent on every request as `X-Kosa8-Actor`. The daemon records it in the [audit log](https://docs.kosa8.com/guides/audit/), and an org policy can treat actors differently, so a program that runs an AI model's commands should say `Actor: "agent"`. `Headers` are added to every request after `Accept` and `X-Kosa8-Actor` (so they can replace those) and before the approval, agent-token and `Authorization` headers (which win). ### Your own http.Client `Options.HTTPClient` replaces the SDK's client entirely. For the local daemon the SDK then sends requests to `http://kosa8/…`, and your transport must dial the socket itself. This ran: ``` sock := kosa8.DefaultSocketPath() hc := &http.Client{Transport: &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { var d net.Dialer return d.DialContext(ctx, "unix", sock) }, MaxIdleConnsPerHost: 16, IdleConnTimeout: 30 * time.Second, }} k, err := kosa8.New(kosa8.Options{HTTPClient: hc, Actor: "agent", Headers: map[string]string{"X-Request-Source": "nightly-job"}}) if err != nil { panic(err) } v, err := k.Version(context.Background()) fmt.Println("via custom client:", v, err, k.Endpoint().Target()) ``` ``` via custom client: 0.9.36 unix:///Users/dhruvit/.kosa8/run/kosa8.sock ``` Do not set `http.Client.Timeout` on a client you use for `Run`, `Exec` or `Attach`: it would cut long streams off at that length. Use contexts instead (next section). ## Contexts, timeouts and cancellation In plain words: every call can be given a deadline ("give up after two seconds") or be called off. Giving up stops your program from *waiting*. It does **not** stop the program running inside the sandbox. In detail: every method that talks to the daemon takes a `context.Context` first, and it is attached to the HTTP request. `RunOptions.Timeout`, when positive, wraps that context with `context.WithTimeout` for that one `Run`, `Exec` or `Attach`. What you get back depends on *how* the context ended: | How it ended | Error | Check with | |---|---|---| | Deadline passed (yours, or `RunOptions.Timeout`) | `*kosa8.Error`, `Kind` `timeout` | `errors.Is(err, kosa8.ErrTimeout)`. Also `errors.Is(err, context.DeadlineExceeded)` when it happened before the answer started. | | Cancelled (`cancel()`, Ctrl-C via `signal.NotifyContext`) | the context's own error, **not** a `*kosa8.Error` | `errors.Is(err, context.Canceled)` | In both cases `Run`, `Exec` and `Attach` return the output collected so far in the `ExecResult`; for a timeout it is also on the error's `Stdout`/`Stderr`. This ran (excerpt from a longer program): ``` // Timeout. t0 := time.Now() res, err = sb.Run(ctx, "alpine:latest", []string{"sh", "-c", "echo started; sleep 30"}, &kosa8.RunOptions{Timeout: 2 * time.Second}) fmt.Printf("timeout after %.1fs: ErrTimeout=%v err=%v partial=%q\n", time.Since(t0).Seconds(), errors.Is(err, kosa8.ErrTimeout), err, res.Stdout()) if e, ok := kosa8.AsError(err); ok { fmt.Printf(" Kind=%s Stdout=%q\n", e.Kind, e.Stdout) } // Cancel. cctx, cancel := context.WithCancel(ctx) go func() { time.Sleep(1500 * time.Millisecond); cancel() }() res, err = sb.Run(cctx, "alpine:latest", []string{"sh", "-c", "echo started; sleep 30"}, nil) _, isK := kosa8.AsError(err) fmt.Printf("cancel: context.Canceled=%v isKosa8Error=%v err=%v partial=%q\n", errors.Is(err, context.Canceled), isK, err, res.Stdout()) ``` ``` timeout after 2.0s: ErrTimeout=true err=POST /kosa8/v1/sandboxes/doc-go-g-run/run timed out mid-stream partial="started\n" Kind=timeout Stdout="started\n" cancel: context.Canceled=true isKosa8Error=false err=context canceled partial="started\n" ``` Later in the same program, `sb.Containers(ctx)` listed both of those `sleep 30` containers as still running: ``` containers entry: map[id:k2-be22c42e image:docker.io/library/alpine:latest ip:10.88.0.3 status:running] containers entry: map[id:k3-10945efa image:docker.io/library/alpine:latest ip:10.88.0.4 status:running] containers entry: map[id:k4-6556b8f4 image:docker.io/library/alpine:latest ip:10.88.0.5 status:running] ``` v0.1.2 has no call to stop one container. To be sure runaway work stops, stop or delete the sandbox (`StopSandbox`, `DeleteSandbox`), or enforce the limit inside the command (`timeout 30 make test`). Two more points: - Deferred clean-up should not use the context that was cancelled, or the delete is cancelled too. Use `context.Background()` (or a fresh short timeout) in the defer, as every program on this page does. - `Explore`'s `Timeout` is per branch, default 120 s; the context you pass covers the whole call. ## Goroutines In plain words: one client can be used from many parts of your program at once. The one thing not to do at the same moment is take two checkpoints of the same sandbox. - **`*Client` is safe for concurrent use.** It holds an `http.Client` (safe), immutable settings, and a checkpoint store guarded by a mutex. Create one client and share it; `Explore` itself runs its branches on goroutines through one client. - **`*Sandbox` handles** are just a client and an id; share them freely. - **Callbacks** (`OnStdout`, `OnStderr`, `OnStatus`, the `ExecFrames` function) run on the goroutine that called the method, one frame at a time, never concurrently with each other for one call. A slow callback slows the stream. - **The checkpoint store** (`FileCheckpointStore`) serialises updates with a mutex in the process and an exclusive `flock` across processes, so the SDK and `kosa8 checkpoint` never interleave writes. - **Two `Checkpoint` calls on the same sandbox at once both succeed.** The next id (`…-ckpt-`) is chosen from a read of the store *before* the snapshot is taken, so both goroutines pick the same id; the daemon refuses the second snapshot with a 409 `already exists`, and from v0.1.1 that call moves on to the next number (up to 10 tries). A 409 for any other reason is returned. This ran: ``` var wg sync.WaitGroup for i := 0; i < 2; i++ { wg.Add(1) go func(i int) { defer wg.Done() c, err := sb.Checkpoint(ctx, fmt.Sprint("race ", i)) fmt.Printf("concurrent checkpoint %d: %s %v\n", i, c.Checkpoint.ID, err) }(i) } wg.Wait() ``` ``` concurrent checkpoint 1: doc-go-g-tick-ckpt-2 concurrent checkpoint 0: doc-go-g-tick-ckpt-3 stack: doc-go-g-tick-ckpt-1 t stack: doc-go-g-tick-ckpt-2 race 1 stack: doc-go-g-tick-ckpt-3 race 0 ``` (The `stack:` lines come from `sb.Checkpoints()` right after.) Which goroutine gets which number is a race, so do not rely on the order. In v0.1.0 the loser failed with `snapshot already exists`. The same stepping covers a checkpoint taken by `kosa8 checkpoint` at the same moment. ## Sandboxes In plain words: you create a sandbox with a name and some limits (how many processors, how much memory, which websites), use it, and delete it. You can also switch it off and on again; its disk is kept while it is off. ### Create ``` created, err := k.CreateSandbox(ctx, kosa8.CreateOptions{ Name: name, CPUs: 2, MemMB: 1024, EgressAllow: []string{"pypi.org", "files.pythonhosted.org"}, Labels: map[string]string{"team": "docs", "job": "guide"}, }) if err != nil { return err } sb := k.Sandbox(name) defer func() { out, err := sb.Delete(context.Background(), kosa8.DeleteOptions{}) fmt.Println("deleted:", out, err) }() s := created["sandbox"].(map[string]any) fmt.Printf("created %s state=%s cpus=%v mem=%v egress=%v labels=%v boot_ms=%v\n", s["name"], s["state"], s["cpus"], s["mem_mb"], s["egress"], s["labels"], created["boot_ms"]) fmt.Println("sandbox keys:", keys(s)) info, _ := sb.Info(ctx) fmt.Println("Info state:", info["state"]) missing, err := k.GetSandbox(ctx, "no-such-sandbox") fmt.Println("GetSandbox(missing):", missing == nil, err) ``` ``` created doc-go-g-run state=running cpus=2 mem=1024 egress=allow:pypi.org,files.pythonhosted.org labels=map[job:guide team:docs] boot_ms=12259 sandbox keys: [cpus created disk_gb egress id ip labels mem_mb name state] Info state: running GetSandbox(missing): true ``` `CreateSandbox` returns when the sandbox has booted. Its answer has `sandbox` (an object) and `boot_ms`. `GetSandbox` (and `sb.Info`) lists every sandbox and matches by id, then by name; a missing sandbox is `nil, nil`, not an error, so check for `nil`. Every `CreateOptions` field, and what the SDK actually sends: | Field | Zero value means | Notes | |---|---|---| | `Name` | the daemon picks a name | Read the real one from `created["sandbox"]`. The daemon accepted `"bad name!"` in our test; the SDK does not check names here (only the checkpoint functions do). | | `CPUs`, `MemMB`, `DiskGB` | the daemon's defaults (here 2 CPUs, 1024 MiB, 32 GB) | Values of 0 or less are not sent. | | `Egress` | the daemon's default (the org policy's, if it sets one) | `deny` (no network), `any`, `fakenet`, or `allow:h1,h2`. Anything else is a 400. See [Network rules](https://docs.kosa8.com/guides/network/). | | `EgressAllow` | not used | Sends `egress: "allow:h1,h2"`, each host trimmed. Combined with an `Egress` other than `""` or `"deny"` it is a `KindUsage` error, before anything is sent. | | `Labels` | none | Free-form key/value tags, returned under `labels`. Not the same as the classification `Label` on forks and restores (below). | | `Mounts` | none | `"HOST:GUEST"` or `"HOST:GUEST:ro"` (`:rw` also accepted), sent as `{host_path, guest_path, read_only}`. Both paths must be absolute. A malformed or relative entry is `KindUsage`. See [below]. (Broken in v0.1.0, fixed in v0.1.1.) | | `Capture` | nil: no capture | `&kosa8.CaptureOptions{Path, MaxBytes, MaxFiles}`: record every network frame to a pcapng file on the host. `Path` is required and is a **file**, not a directory. See [below]. (A `bool` that never worked in v0.1.0.) | | `Broker` | none | Passed through as JSON, for the credential broker ([Keep API keys out](https://docs.kosa8.com/guides/credentials/)). Not tested. | | `Approval` | none | Sent as `X-Kosa8-Approval`. | ### Images, containers and egress state ``` p, err := sb.Pull(ctx, "alpine:latest") fmt.Println("pull:", p, err) imgs, _ := sb.Images(ctx) fmt.Println("images:", imgs) // … later eg, err := sb.Egress(ctx) fmt.Println("egress:", eg, err) ``` ``` pull: map[image:docker.io/library/alpine:latest ok:true] images: [docker.io/library/alpine:latest] egress: map[policy:allow:pypi.org,files.pythonhosted.org stats:map[denied:0 dns_answered:4 dns_refused:0 dropped:1 passed:3]] ``` `sb.Pull` (`PullIntoSandbox`) fetches the image on the host and copies it into the sandbox, so it works with `Egress: "deny"`. `k.Pull` is different: it pulls into the host's shared engine and does not make an image usable in any sandbox. `sb.Egress` shows the enforced rule and its counters, which is the quickest way to see whether a failing download was blocked. ### Share a host folder, record the traffic In plain words: a **mount** lets the sandbox see one folder of your computer, and read-only means it can look but not change anything. A **capture** writes down every network message the sandbox sends or receives, to a file you can open in Wireshark. Code inside the sandbox cannot see or stop the capture. ``` host, _ := filepath.Abs(os.Args[1]) // a folder on the host pcapDir, _ := filepath.Abs(os.Args[2]) pcap := filepath.Join(pcapDir, "traffic.pcapng") // a file, not a folder _, err = k.CreateSandbox(ctx, kosa8.CreateOptions{Name: "doc-go-g-x", Mounts: []string{"relative-path-without-guest"}}) fmt.Println("bad mount:", errors.Is(err, kosa8.ErrUsage), err) _, err = k.CreateSandbox(ctx, kosa8.CreateOptions{Name: "doc-go-g-x", Capture: &kosa8.CaptureOptions{}}) fmt.Println("capture without path:", errors.Is(err, kosa8.ErrUsage), err) const name = "doc-go-g-mount" created, err := k.CreateSandbox(ctx, kosa8.CreateOptions{ Name: name, Mounts: []string{host + ":/work:ro"}, EgressAllow: []string{"example.com"}, Capture: &kosa8.CaptureOptions{Path: pcap, MaxFiles: 2}, }) if err != nil { return err } sb := k.Sandbox(name) defer func() { out, err := sb.Delete(context.Background(), kosa8.DeleteOptions{}) fmt.Println("deleted:", out, err) }() s := created["sandbox"].(map[string]any) fmt.Println("mounts:", s["mounts"], "capture:", s["capture"]) if _, err := sb.Pull(ctx, "alpine:latest"); err != nil { return err } res, err := sb.Run(ctx, "alpine:latest", []string{"sh", "-c", "mount | grep /work; cat /work/input.txt; echo x > /work/new.txt || echo write refused; wget -q -T 5 -O /dev/null http://example.com && echo fetched example.com"}, nil) if err != nil { return err } fmt.Printf("exit %d\n%s%s", res.ExitCode, res.Stdout(), res.Stderr()) entries, _ := os.ReadDir(pcapDir) for _, e := range entries { fi, _ := e.Info() fmt.Printf("capture file: %s (%d bytes)\n", e.Name(), fi.Size()) } ``` ``` $ go run . hostdir pcap bad mount: true mount "relative-path-without-guest": want host:guest or host:guest:ro capture without path: true Capture.Path is required: the host file the capture is written to mounts: [map[guest_path:/work host_path:/private/tmp/…/go/hostdir read_only:true]] capture: /private/tmp/…/go/pcap/traffic.pcapng exit 0 overlay on / type overlay (rw,relatime,lowerdir=/var/lib/containerd/…,index=off,uuid=on) kosa8m0 on /work type virtiofs (ro,relatime) hello from the host write refused fetched example.com sh: can't create /work/new.txt: Read-only file system capture file: traffic.pcapng (3588 bytes) deleted: map[checkpoints_dropped:0 ok:true] ``` The folder appears at `/work` inside containers that `Run` starts, shared with virtiofs and read-only, so the write was refused. (The `overlay` line matched `grep /work` because of its `workdir=`.) Rules: - Each `Mounts` entry is `"HOST:GUEST"`, `"HOST:GUEST:ro"` or `"HOST:GUEST:rw"`. Anything else (one part, an empty part, another suffix) is `KindUsage` before sending. Both paths must be absolute: from v0.1.2 a relative host path, or a guest path not starting with `/`, is `KindUsage` too, so resolve the host side with `filepath.Abs` as above. A colon inside a path cannot be expressed. - An org policy, or the classification label a sandbox has, can forbid mounts; that would be a 403 (not tested here). - `Capture.Path` is the pcapng **file** to write, as with `kosa8 sandbox create --pcap FILE`. A directory is refused by the daemon with HTTP 500 `capture: open …/pcap: is a directory` (v0.1.1's comment and usage message wrongly said "directory"; v0.1.2 fixes the wording). `MaxBytes` (per segment) and `MaxFiles` (segments kept) are sent only when positive; otherwise the daemon's defaults apply (64 MiB and 8 for the CLI). - The file kept growing after the program read it (7056 bytes once the sandbox was deleted), so read a capture after the sandbox is stopped or deleted. It is owner-only (mode 0600) and holds everything sent in the clear; treat it as a secret. ### Stop, start, delete ``` st, err := sb.Stop(ctx) fmt.Println("stop:", st, err) info, _ = sb.Info(ctx) fmt.Println("after stop:", info["state"]) up, err := sb.Start(ctx) if err == nil { fmt.Println("start keys:", keys(up), "state:", up["sandbox"].(map[string]any)["state"]) } imgs, _ = sb.Images(ctx) fmt.Println("images after restart:", imgs) ``` ``` stop: map[ok:true sandbox:map[cpus:2 created:2026-11-03T02:20:25.65452-05:00 disk_gb:32 egress:allow:pypi.org,files.pythonhosted.org id:doc-go-g-run labels:map[job:guide team:docs] mem_mb:1024 name:doc-go-g-run state:stopped] warning:] after stop: stopped start keys: [boot_ms sandbox warning] state: running images after restart: [docker.io/library/alpine:latest] deleted: map[checkpoints_dropped:0 ok:true] ``` Stopping keeps the disk, so pulled images survive; running containers and memory do not. A stopped sandbox refuses work with a 409 (`sandbox is not running`). `DeleteSandbox` removes the sandbox and then, unless `KeepCheckpoints`, its checkpoint records and their snapshots; `checkpoints_dropped` says how many. Errors deleting those snapshots are ignored, so a sandbox delete never fails because of checkpoint debris. `Wipe` overwrites disks instead of unlinking them (sent as `?wipe=1`, also to each checkpoint snapshot; not tested here). ## Running code In plain words: there are four ways to run something, depending on whether it should start fresh, keep its files between commands, read input you type, or report every line as it happens. | You want | Use | Behaviour | |---|---|---| | Run one command, from a clean image, and get its output | `Run` | New container each time; waits for exit. Sends `RunOptions.Env`. | | Several commands sharing files and processes | `StartContainer` then `Exec` | The container lives until the sandbox stops. `Exec` refuses `RunOptions.Env` (`KindUsage`). | | Feed a program input | `StartInteractive`, `WriteStdin`, `Attach` | One `WriteStdin` per container: its reader is the whole of stdin. | | Handle raw output frames yourself | `ExecFrames` | Your function sees each frame; return an error to stop. | Two rules apply to all of them. A command that exits non-zero is a **result**, not an error: `err` is nil and `res.ExitCode` is set; `res.OK()` is `ExitCode == 0`. And `err` is set when the command could not run at all: no such image in the sandbox, no such executable, no such container, the daemon unreachable. Those come back as `KindStream` or an HTTP kind; see [the errors page](https://docs.kosa8.com/guides/go-errors/#stream). ### Run, with environment and streaming ``` var chunks int res, err := sb.Run(ctx, "alpine:latest", []string{"sh", "-c", "for i in 1 2 3; do echo line $i; sleep 0.3; done; echo oops >&2; echo GREETING=$GREETING"}, &kosa8.RunOptions{ Env: []string{"GREETING=hello"}, OnStdout: func(b []byte) { chunks++; fmt.Printf(" [%s] stdout %q\n", time.Now().Format("15:04:05.0"), b) }, OnStderr: func(b []byte) { fmt.Printf(" stderr %q\n", b) }, }) if err != nil { return err } fmt.Printf("run exit=%d ok=%v chunks=%d stdout=%q stderr=%q\n", res.ExitCode, res.OK(), chunks, res.Stdout(), res.Stderr()) ``` ``` [02:20:27.8] stdout "line 1\n" [02:20:28.1] stdout "line 2\n" [02:20:28.4] stdout "line 3\n" stderr "oops\n" [02:20:28.7] stdout "GREETING=hello\n" run exit=0 ok=true chunks=4 stdout="line 1\nline 2\nline 3\nGREETING=hello\n" stderr="oops\n" ``` The timestamps show output arriving as it is produced, 0.3 s apart. Callbacks get each chunk, and the chunks are also collected into the result, so you can stream to a log and still read the whole output afterwards. A chunk is whatever the daemon sent in one frame, not necessarily one line; buffer if you need lines. `OnStatus` gets progress frames, which are not collected. `args` is an argv, not a shell line: use `[]string{"sh", "-c", "…"}` for pipes and redirects. A nil `args` is sent as `[]` (the image's default command). The image must already be in the sandbox. ### A long-lived container and Exec ``` cid, err := sb.StartContainer(ctx, "alpine:latest", []string{"sh", "-c", "while :; do sleep 3600; done"}) if err != nil { return err } fmt.Println("container:", cid) _, err = sb.Exec(ctx, cid, []string{"sh", "-c", "echo FOO=$FOO"}, &kosa8.RunOptions{Env: []string{"FOO=bar"}}) fmt.Println("exec with Env:", errors.Is(err, kosa8.ErrUsage), err) r, err := sb.Exec(ctx, cid, []string{"env", "FOO=bar", "sh", "-c", "echo state > /tmp/x; cat /tmp/x; echo HOME=$HOME FOO=$FOO"}, nil) fmt.Printf("exec: %q %v\n", r.Stdout(), err) ``` ``` container: k4-6556b8f4 exec with Env: true Exec does not take Env: the container keeps the environment it was started with. Set it at StartInteractive (InteractiveOptions.Env), or wrap the command: []string{"env", "K=V", "cmd"} exec: "state\nHOME=/root FOO=bar\n" ``` The daemon's exec takes no environment, so **`Exec` with `RunOptions.Env` is a `KindUsage` error**, before anything is sent. (v0.1.0 dropped the variables silently.) Wrap the command with `env K=V …`, as above, or set variables in a `sh -c` line, or start the container with `StartInteractive`, whose `InteractiveOptions.Env` is sent (`StartContainer` takes no env). The container runs until it exits or the sandbox stops; a checkpoint saves it, and a rewind brings it back with the same id. ### Raw frames Output travels as NDJSON frames. `ExecFrames` hands you each one, decoded: ``` err = k.ExecFrames(ctx, name, cid, []string{"sh", "-c", "echo a; echo b >&2; exit 4"}, func(f kosa8.Frame) error { exit := "nil" if f.Exit != nil { exit = fmt.Sprint(*f.Exit) } fmt.Printf(" frame stream=%q data=%q done=%v ok=%v exit=%s error=%q\n", f.Stream, f.Data, f.Done, f.OK, exit, f.Error) return nil }) fmt.Println("ExecFrames err:", err) // Stop early from fn. stop := errors.New("seen enough") n := 0 err = k.ExecFrames(ctx, name, cid, []string{"sh", "-c", "for i in 1 2 3 4 5; do echo $i; sleep 0.2; done"}, func(f kosa8.Frame) error { n++ if n == 2 { return stop } return nil }) fmt.Println("ExecFrames stopped:", errors.Is(err, stop), n) // An error frame is passed to fn, then returned. err = k.ExecFrames(ctx, name, "k99-nope", []string{"true"}, func(f kosa8.Frame) error { fmt.Printf(" frame done=%v error=%q\n", f.Done, f.Error) return nil }) fmt.Println("ExecFrames on a missing container:", errors.Is(err, kosa8.ErrStream), err) ``` ``` frame stream="stderr" data="b\n" done=false ok=false exit=nil error="" frame stream="stdout" data="a\n" done=false ok=false exit=nil error="" frame stream="" data="" done=true ok=true exit=4 error="" ExecFrames err: ExecFrames stopped: true 2 frame done=true error="no such container: k99-nope" ExecFrames on a missing container: true no such container: k99-nope ``` The last frame has `Done`, and either `OK` with `Exit`, or `Error`. Note `OK` is true with exit 4: it means "the command ran", not "it succeeded". stderr arrived before stdout in this run (it was the other way round in an earlier one): the order *between* the two streams is not guaranteed, only the order within each. A final frame with an `Error` is passed to your function and then returned as a `KindStream` error (v0.1.0 returned nil). Your function's error is returned as is, so `errors.Is` works on it. Stopping early closes the stream; nothing in v0.1.2 tells the container to stop the command. ### Standard input: StartInteractive, WriteStdin, Attach ``` icid, err := k.StartInteractive(ctx, name, "alpine:latest", []string{"sh", "-c", "tr a-z A-Z"}, kosa8.InteractiveOptions{}) if err != nil { return err } fmt.Println("interactive:", icid) done := make(chan struct{}) go func() { defer close(done) ar, err := k.Attach(ctx, name, icid, &kosa8.RunOptions{OnStdout: func(b []byte) { fmt.Printf(" attach got %q\n", b) }}) fmt.Printf("attach: exit=%d out=%q err=%v\n", ar.ExitCode, ar.Stdout(), err) }() time.Sleep(500 * time.Millisecond) w, err := k.WriteStdin(ctx, name, icid, strings.NewReader("hello from stdin\nsecond line\n")) fmt.Println("WriteStdin:", w, err) <-done _, err = k.WriteStdin(ctx, name, icid, strings.NewReader("more\n")) fmt.Println("second WriteStdin:", errors.Is(err, kosa8.ErrUsage), err) ``` ``` interactive: k8-e5df7b9b attach got "HELLO FROM STDIN\nSECOND LINE\n" WriteStdin: map[ok:true] attach: exit=0 out="HELLO FROM STDIN\nSECOND LINE\n" err= second WriteStdin: true stdin of container k8-e5df7b9b in doc-go-g-run was already sent and is closed: the daemon treats one WriteStdin body as the whole of stdin. Pass an io.Pipe reader to stream input ``` Start the container, attach on a goroutine (so you see output while you write), then write. `WriteStdin` streams the reader as the request body (chunked when its length is unknown) and closes the container's stdin when the reader returns EOF, which is what made `tr` finish. So the reader is the *whole* of stdin: a second `WriteStdin` to the same container through the same client is a `KindUsage` error, sent nothing (v0.1.0 answered ok for input the program never saw). The check is per `*Client`; another client, or another process, is not stopped. For a conversation with a long-running program, pass an `io.Pipe` reader and write to the pipe as you go; close the writer to end input (not run for this page). `Attach` returns when the container exits. `InteractiveOptions.TTY` asks for a terminal (not tested here), and its `Env` is sent. ## Checkpoints and rewind In plain words: a checkpoint is an undo point for the whole sandbox, including programs that are in the middle of running. Rewind goes back to one. The list of undo points is shared with the `kosa8` command, so you can take a checkpoint in Go and undo it from the terminal, or the other way round. ### What a checkpoint saves, shown A container counts up once a second into `/tick`. We checkpoint, wait, rewind, wait: ``` cid, _ := sb.StartContainer(ctx, "alpine:latest", []string{"sh", "-c", "i=0; while :; do i=$((i+1)); echo $i > /tick; sleep 1; done"}) read := func(l string) { r, err := sb.Exec(ctx, cid, []string{"sh", "-c", "cat /tick; date +%T"}, nil) fmt.Printf("%-18s %q %v\n", l, strings.ReplaceAll(r.Stdout(), "\n", " "), err) } read("start") time.Sleep(3 * time.Second) read("+3s") cp, err := sb.Checkpoint(ctx, "t") fmt.Println("checkpoint", cp.Checkpoint.ID, err) read("after checkpoint") time.Sleep(3 * time.Second) read("+3s") _, err = sb.Rewind(ctx, "") fmt.Println("rewind", err) read("after rewind") time.Sleep(3 * time.Second) read("+3s") ``` ``` start "1 07:21:08 " +3s "4 07:21:11 " checkpoint doc-go-g-tick-ckpt-1 after checkpoint "4 07:21:11 " +3s "7 07:21:14 " rewind after rewind "4 07:21:15 " +3s "7 07:21:18 " ``` After the rewind the counter is back at 4, the value it had at the checkpoint, and keeps counting: the process was frozen and resumed, not restarted. The clock shows the real time, not the checkpoint's. The same container id works before and after. ### Shared with the CLI ``` sh("echo v0 > /app.txt") c1, err := sb.Checkpoint(ctx, "clean") if err != nil { return err } fmt.Printf("checkpoint %s label=%q created=%s snapshot_ms=%v evicted=%v tick=%s\n", …) fmt.Println("snapshot answer keys:", keysOf(c1.Snapshot)) sh("echo v1 > /app.txt") c2, _ := sb.Checkpoint(ctx, "step 1") fmt.Println("checkpoint", c2.Checkpoint.ID) // The CLI sees the SDK's checkpoints, and the other way round. cli("kosa8", "checkpoint", "ls", name) cli("kosa8", "checkpoint", name, "--label", "taken by the CLI") list, _ := sb.Checkpoints() for _, c := range list { fmt.Printf(" sdk sees %-22s %-18q %s\n", c.ID, c.Label, c.Created.Format("15:04:05")) } sh("echo broken > /app.txt") rw, err := sb.Rewind(ctx, "CLEAN") // labels match without regard to case if err != nil { return err } fmt.Printf("rewound to %s in %vms, dropped %v\n", rw.Checkpoint.ID, rw.RestoreMS, rw.Dropped) fmt.Println("after rewind: app.txt =", sh("cat /app.txt")) _, err = sb.Rewind(ctx, "step 1") fmt.Println("rewind to a dropped one:", err) ``` ``` checkpoint doc-go-g-ckpt-ckpt-1 label="clean" created=07:24:21 snapshot_ms=570 evicted=[] tick=1 snapshot answer keys: [snapshot snapshot_ms] checkpoint doc-go-g-ckpt-ckpt-2 $ kosa8 checkpoint ls doc-go-g-ckpt CHECKPOINT WHEN TAKEN WHY doc-go-g-ckpt-ckpt-1 just now you clean *doc-go-g-ckpt-ckpt-2 just now you step 1 * is where `kosa8 rewind doc-go-g-ckpt` goes $ kosa8 checkpoint doc-go-g-ckpt --label taken by the CLI checkpoint doc-go-g-ckpt-ckpt-3 (taken by the CLI) saved in 577ms rewind with `kosa8 rewind doc-go-g-ckpt` sdk sees doc-go-g-ckpt-ckpt-1 "clean" 07:24:21 sdk sees doc-go-g-ckpt-ckpt-2 "step 1" 07:24:21 sdk sees doc-go-g-ckpt-ckpt-3 "taken by the CLI" 07:24:22 rewound to doc-go-g-ckpt-ckpt-1 in 459ms, dropped [doc-go-g-ckpt-ckpt-2 doc-go-g-ckpt-ckpt-3] after rewind: app.txt = v0 rewind to a dropped one: no checkpoint "step 1" for sandbox "doc-go-g-ckpt" ``` The store is the file `~/.kosa8/checkpoints.json`. This is the sandbox's entry as the program read it back, before the rewind: ``` { "doc-go-g-ckpt": [ { "id": "doc-go-g-ckpt-ckpt-1", "sandbox": "doc-go-g-ckpt", "label": "clean", "created": "2026-11-03T07:24:21.074969Z" }, { "id": "doc-go-g-ckpt-ckpt-2", "sandbox": "doc-go-g-ckpt", "label": "step 1", "created": "2026-11-03T07:24:21.755216Z" }, { "id": "doc-go-g-ckpt-ckpt-3", "sandbox": "doc-go-g-ckpt", "label": "taken by the CLI", "created": "2026-11-03T07:24:22.367086Z" } ] } ``` ### The rules, precisely - **Checkpoint** picks the id `-ckpt-`, where n is one more than both the stack size and the highest number already used; takes a snapshot with that id (`POST …/snapshot`); deletes evicted snapshots; then records the checkpoint. Only the record is written under the lock (see [Goroutines]). - **At most 10** (`MaxCheckpointsPerSandbox`) per sandbox. Taking the 11th evicts the oldest, and only after the new snapshot exists; `CheckpointResult.Evicted` lists the ids removed. - **Rewind**'s `to` is a checkpoint id (exact) or a label (case-insensitive, newest match first); `""` is the latest. It deletes the sandbox, restores the snapshot under the same name, then drops the checkpoints taken after the target (record and snapshot) and returns them in `Dropped`. The current state is lost. The target itself is kept, so you can rewind to it again, as the agent loop below does. - **Order matters when it fails.** If the delete is refused, nothing has changed and that error comes back unchanged. If the restore fails after the delete, the sandbox is gone and the snapshot is intact; the error is `KindCheckpoint`, wraps the daemon's error, and its message ends with the exact `RestoreSnapshot` call that recovers it. On a Mac, a locked screen is the usual cause; see [the errors page](https://docs.kosa8.com/guides/go-errors/#locked). - **Names** that are empty, `.`, `..`, start with a dot or contain `/` or `\` are refused with `KindCheckpoint` before anything is sent. - **The file** is written with mode 0600 to `checkpoints.json.tmp` and renamed over the original, under an exclusive `flock` on `checkpoints.json.lock`, the same lock the CLI takes. A file that is not valid JSON reads as empty rather than failing. On systems without `flock` only the in-process mutex applies. - **Rewind** and **ListCheckpoints** read only the store. From the source: a checkpoint whose snapshot was deleted some other way (say `kosa8 snapshot rm`) still lists, and `Rewind` to it deletes the sandbox before the restore fails. Delete checkpoint snapshots only through `Rewind` or `DeleteSandbox`. ### A private store `Options.Checkpoints` takes any `CheckpointStore` (`Load` and `Update`). `&kosa8.MemoryCheckpointStore{}` keeps stacks in memory, shared with nothing; `kosa8.NewFileCheckpointStore(path)` uses another file. Useful in tests, or for a service that must not see the user's own checkpoints: ``` mem := &kosa8.MemoryCheckpointStore{} k2, _ := kosa8.New(kosa8.Options{Checkpoints: mem}) l2, _ := k2.ListCheckpoints(name) fmt.Println("memory store sees:", len(l2)) ``` ``` memory store sees: 0 ``` With a private store, `kosa8 rewind` cannot see your checkpoints, and `DeleteSandbox` on another client will not clean them up. Errors from your own store come back as you returned them, not as `*kosa8.Error`. ## Snapshots In plain words: a snapshot is the raw saved copy that a checkpoint is built on. Use snapshots directly when you want a saved state that is not tied to one sandbox's undo list, for example a "ready to use" sandbox you start new ones from. | Method | What it does | |---|---| | `Snapshot(ctx, sandbox, SnapshotOptions{ID, Encrypt})` | Saves memory, processes and disks. Without `ID` the daemon picks one. The sandbox must be running (a stopped one is a 500 `sandbox is not running`). An id already in use is a 409. | | `ListSnapshots(ctx)` | Every snapshot on the daemon, checkpoints included. | | `RestoreSnapshot(ctx, id, RestoreOptions{Name, Label, AllowTampered})` | Boots a *new* sandbox from it, processes resumed; the answer has `sandbox` and `restore_ms`. The snapshot stays. | | `DeleteSnapshot(ctx, id, SnapshotDeleteOptions{Wipe, Force})` | Removes it. `?wipe=1`, `?force=1` when set. | A snapshot you take with `Snapshot` is not a checkpoint: it is not in the store, `Rewind` will not find it, and `DeleteSandbox` does not delete it. Clean it up yourself. **`Label` on forks and restores is a security level, not a description** `ForkOptions.Label` and `RestoreOptions.Label` are classification levels defined by the org's [label policy](https://docs.kosa8.com/guides/governance/) (here `UNCLASSIFIED` and `CUI`; see `kosa8 label ls`). A free-text value is refused with a 403: ``` refused by label policy: label "experiment": level "EXPERIMENT" is not defined (defined: UNCLASSIFIED, CUI); an org policy defines levels under "labels" (a fork keeps its parent's label or raises it; see `kosa8 label ls`) ``` To tag a sandbox with a description, use `CreateOptions.Labels`, or the checkpoint label, which is free text. ## Fork and explore In plain words: forking makes several identical copies of a running sandbox in a second or two, so you can try a different idea in each without the ideas getting in each other's way. Explore does the whole "try them all, keep the best" job in one call. ### Fork N ways, by hand ``` // Before this: a container cid in the parent writes the time to /now every second, // and the program printed "parent: started … now …" from it. fr, err := sb.Fork(ctx, 3, kosa8.ForkOptions{Prefix: name + "-b"}) if err != nil { return err } var ids []string for _, f := range fr.Forks { ids = append(ids, f["id"].(string)) } defer func() { for _, id := range ids { _, err := k.DeleteSandbox(context.Background(), id, kosa8.DeleteOptions{}) fmt.Println("deleted fork", id, err) } }() fmt.Printf("forked %d in %dms: %v\n", fr.Count, fr.ForkMS, ids) fmt.Printf("fork entry: %v\nsnapshot: %v\n", fr.Forks[0], fr.Snapshot) // Try a different approach in each, concurrently. approaches := []string{"echo A > /choice", "echo B > /choice; exit 1", "echo C > /choice"} type out struct { id, text string code int err error } results := make([]out, len(ids)) var wg sync.WaitGroup for i, id := range ids { wg.Add(1) go func(i int, id string) { defer wg.Done() res, err := k.Exec(ctx, id, cid, []string{"sh", "-c", approaches[i] + "; echo $(cat /choice) started $(cat /started) now $(cat /now)"}, nil) results[i] = out{id, strings.TrimSpace(res.Stdout()), res.ExitCode, err} }(i, id) } wg.Wait() for _, o := range results { fmt.Printf(" %s exit=%d %q err=%v\n", o.id, o.code, o.text, o.err) } r, _ = sb.Exec(ctx, cid, []string{"sh", "-c", "cat /choice 2>&1; echo now $(cat /now)"}, nil) fmt.Print("parent untouched: ", r.Stdout()) ``` ``` parent: started 1793690768 now 1793690771 forked 3 in 3587ms: [doc-go-g-fork-b1 doc-go-g-fork-b2 doc-go-g-fork-b3] fork entry: map[cpus:2 created:2026-11-03T02:26:13.671777-05:00 disk_gb:32 egress:deny id:doc-go-g-fork-b1 ip:192.168.127.2 mem_mb:1024 name:doc-go-g-fork-b1 parent:doc-go-g-fork state:running] snapshot: doc-go-g-fork-fork-1793690771654953000 doc-go-g-fork-b1 exit=0 "A started 1793690768 now 1793690774" err= doc-go-g-fork-b2 exit=1 "" err= doc-go-g-fork-b3 exit=0 "C started 1793690768 now 1793690774" err= parent untouched: cat: can't open '/choice': No such file or directory now 1793690773 ``` Here `cid` is a container started in the parent before the fork; the same id works in every fork, because each fork is a copy of the running parent. Forks are named `1…N` (default prefix `-fork`), keep the parent's egress rule, and record it as `parent`. Changes in one fork do not reach the others or the parent. `n` below 1 is sent as 1. Forks are ordinary sandboxes: delete each one yourself, as the deferred loop does. The fork's snapshot (`fr.Snapshot`) did not appear in `ListSnapshots` afterwards. ### Explore: best of N in one call ``` t0 := time.Now() ex, err := sb.Explore(ctx, []string{"sleep 2; echo slow", "echo fast", "echo broken; exit 3"}, kosa8.ExploreOptions{NoPull: true, Timeout: 60 * time.Second}) if err != nil { return err } fmt.Printf("explore in %.1fs: winner=%s removed=%d\n", time.Since(t0).Seconds(), ex.Winner, ex.Removed) for _, b := range ex.Branches { fmt.Printf(" %-26s exit=%d %5dms %-22q output=%q error=%q\n", b.Name, b.Exit, b.DurationMS, b.Variant, b.Output, b.Error) } if ex.Winner != "" { ids = append(ids, ex.Winner) // clean it up with the forks } ``` ``` explore in 87.5s: winner=doc-go-g-fork-explore-907752 removed=2 doc-go-g-fork-explore-907751 exit=0 2279ms "sleep 2; echo slow" output="slow" error="" doc-go-g-fork-explore-907752 exit=0 250ms "echo fast" output="fast" error="" doc-go-g-fork-explore-907753 exit=3 256ms "echo broken; exit 3" output="broken" error="" deleted fork doc-go-g-fork-explore-907752 ``` What `Explore` does, in order: ### Pull `PullIntoSandbox(Image)`, default `alpine:latest`, unless `NoPull`. ### Fork One fork per variant, prefix `-explore-` unless `Prefix` is set. The variants above took under 3 s each, so most of the 87.5 s went on forking and deleting, on a busy machine. ### Run `sh -c VARIANT` with `Run` in each branch, all at once on goroutines, each with `Timeout` (default 120 s). Connection-level failures (refused, reset, EOF, …) are retried up to four times, 0.5, 1 and 1.5 s apart, because a freshly restored branch can refuse its first connection. `Output` is stdout then stderr, trailing newlines trimmed. A branch that could not run gets `Error`, not a failed call. ### Pick and clean up The winner is the branch with no error, exit 0 and the smallest `DurationMS`, or `Keep` if you named one. Every other branch is deleted unless `KeepAll`; `Removed` counts the deletes that succeeded. If no branch succeeds, `Winner` is `""` and every branch is deleted. The parent is untouched, and the winner is a separate sandbox that you own and must delete. `Explore` needs at least two variants (`KindUsage` otherwise). If `Keep` names no branch, the call returns the result with every branch kept and a `KindUsage` error. Each variant runs in a *fresh* container from `Image`, so files written inside the parent's own containers are not visible to it; when the variants need that state, fork by hand and `Exec` in the forked container, as in the previous section. ## Recipes ### An agent loop with undo In plain words: before each step an AI agent wants to take, save an undo point. If the step breaks something, go back. The agent can then try something else, and nothing it did wrong survives. ``` package main import ( "context" "fmt" "os" "strings" "time" kosa8 "github.com/kosa8ai/kosa8-go" ) // proposals stands in for an AI model: each step is a shell command it wants to run. var proposals = []string{ "mkdir -p /app && echo 'mode=ok' > /app/config", "echo 'retries=3' >> /app/config", "rm -rf /app", // a bad idea "echo 'timeout=30' >> /app/config", } // check is your own test of whether the sandbox is still in a good state. const check = "grep -q mode=ok /app/config" func main() { if err := run(context.Background()); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } } func run(ctx context.Context) error { k, err := kosa8.New(kosa8.Options{Actor: "agent"}) if err != nil { return err } const name = "doc-go-g-agent" if _, err := k.CreateSandbox(ctx, kosa8.CreateOptions{Name: name, Egress: "deny"}); err != nil { return err } sb := k.Sandbox(name) defer func() { out, err := sb.Delete(context.Background(), kosa8.DeleteOptions{}) fmt.Println("cleanup:", out, err) }() if _, err := sb.Pull(ctx, "alpine:latest"); err != nil { return err } cid, err := sb.StartContainer(ctx, "alpine:latest", []string{"sleep", "infinity"}) if err != nil { return err } for i, cmd := range proposals { cp, err := sb.Checkpoint(ctx, fmt.Sprintf("before step %d", i+1)) if err != nil { return err } res, err := sb.Exec(ctx, cid, []string{"sh", "-c", cmd + " && " + check}, &kosa8.RunOptions{Timeout: time.Minute}) if err != nil { return err // could not run at all: stop, do not guess } if res.OK() { fmt.Printf("step %d ok: %s\n", i+1, cmd) continue } rw, err := sb.Rewind(ctx, cp.Checkpoint.ID) if err != nil { return err } fmt.Printf("step %d undone: %s (exit %d; rewound to %s in %vms)\n", i+1, cmd, res.ExitCode, rw.Checkpoint.ID, rw.RestoreMS) } res, err := sb.Exec(ctx, cid, []string{"cat", "/app/config"}, nil) if err != nil { return err } fmt.Print("final /app/config:\n", res.Stdout()) list, _ := sb.Checkpoints() var ids []string for _, c := range list { ids = append(ids, strings.TrimPrefix(c.ID, name+"-")) } fmt.Println("checkpoints kept:", ids) return nil } ``` ``` $ go run . step 1 ok: mkdir -p /app && echo 'mode=ok' > /app/config step 2 ok: echo 'retries=3' >> /app/config step 3 undone: rm -rf /app (exit 2; rewound to doc-go-g-agent-ckpt-3 in 480ms) step 4 ok: echo 'timeout=30' >> /app/config final /app/config: mode=ok retries=3 timeout=30 checkpoints kept: [ckpt-1 ckpt-2 ckpt-3 ckpt-4] cleanup: map[checkpoints_dropped:4 ok:true] ``` Points worth copying: rewind to the id you just took (`cp.Checkpoint.ID`), not `""`, so a checkpoint taken elsewhere in between cannot change the target; treat `err != nil` from `Exec` as "stop", because it means the command did not run, not that it failed; keep the stack bounded (it is: 10 per sandbox, oldest evicted); and give the model the output (`res.Stdout()`, `res.Stderr()`) of a step that was undone, so it learns why. In a real agent, also set an egress rule that allows only what the agent needs; see [Securing an agent](https://docs.kosa8.com/guides/secure-agents/). ### Clean up no matter what Sandboxes keep running, and using memory and disk, until they are deleted. A program that crashes or is interrupted before deleting leaves them behind. The pattern that held up in every program on this page: ``` func main() { // Ctrl-C cancels ctx instead of killing the process, so defers run. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() if err := run(ctx); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) // after run returned: its defers have already run } } func run(ctx context.Context) error { k, err := kosa8.New(kosa8.Options{}) if err != nil { return err } if _, err := k.CreateSandbox(ctx, kosa8.CreateOptions{Name: "job-1", Egress: "deny"}); err != nil { return err // nothing to clean up: the create failed } defer func() { // Not ctx: it may be the reason we are leaving. dctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() if _, err := k.DeleteSandbox(dctx, "job-1", kosa8.DeleteOptions{}); err != nil && !errors.Is(err, kosa8.ErrNotFound) { fmt.Fprintln(os.Stderr, "cleanup:", err) } }() // … work … return nil } ``` We tested it with the work replaced by a `Run` of `sleep 120`, and sent the program `SIGINT` (what Ctrl-C sends) 12 seconds after the sandbox appeared. The `Run` returned `context.Canceled`, the deferred delete ran, and `kosa8 sandbox ls` showed nothing left: ``` created; working run: context canceled error: context canceled ``` The program then exited with status 1, from `os.Exit(1)` in `main`. - Do the work in a function that returns an error, and call `os.Exit` only in `main`. `os.Exit` and `log.Fatal` skip deferred calls. - Register the delete right after the create succeeds. A failed create left nothing behind in our tests. - Delete forks and the explore winner too; they are separate sandboxes with their own lifetimes. - If a program died anyway, find leftovers with `kosa8 sandbox ls` (or `ListSandboxes`) and `kosa8 snapshot ls`. Give your sandboxes a common name prefix so they are easy to spot. ### Retry a create on a busy machine Booting needs free memory. On our busy test machine, creates sometimes failed with HTTP 500 `guest not ready on vsock port 1024 after 25s` and worked on a later try. If your program creates sandboxes under load, retry a few times with a pause, and only on that kind of error: ``` func createWithRetry(ctx context.Context, k *kosa8.Client, o kosa8.CreateOptions) (kosa8.Info, error) { var err error for attempt := 0; attempt < 3; attempt++ { var info kosa8.Info if info, err = k.CreateSandbox(ctx, o); err == nil { return info, nil } e, ok := kosa8.AsError(err) if !ok || e.Status != 500 || !strings.Contains(e.DaemonMessage, "guest not ready") { return nil, err // a real refusal: do not retry } select { case <-time.After(30 * time.Second): case <-ctx.Done(): return nil, ctx.Err() } } return nil, err } ``` This function compiles against v0.1.2; the retry was done by a shell loop around the test programs rather than by this exact code. ## Approvals and policy An org policy can refuse an action (HTTP 403, `ErrPolicy`) or require a signed approval for it (`ErrApprovalRequired`, with the daemon's refusal in `e.Approval`). Once someone has approved the request, pass the approval id with the call: `sb.Run(ctx, img, args, &kosa8.RunOptions{Approval: id})`, `sb.Stop(ctx, kosa8.Call{Approval: id})`. The CLI describes an approval as spent by the one call it covers. The SDK's compound methods (`Checkpoint`, `Rewind`, `Explore`, `DeleteSandbox`) send the same approval on every request they make, for example the delete *and* the restore of a rewind; if the policy guards more than one of those steps, one approval may not be enough. No policy was installed on the test machine, so this section is from the source; see [Company rules](https://docs.kosa8.com/guides/governance/) and [handling policy errors](https://docs.kosa8.com/guides/go-errors/#policy). ## Versions | Version | What changed | |---|---| | `v0.1.2` | `Version` reports `"0.1.2"` (v0.1.1 still said `"0.1.0"`). `CaptureOptions.Path` is documented as the host file, and its usage error says so. Mounts with a relative host path or a guest path not starting with `/` are `KindUsage`. The `Exec` Env error points at `InteractiveOptions.Env`. | | `v0.1.1` | `Mounts` work. `Capture` became `*CaptureOptions`. `Exec` with `Env` and a second `WriteStdin` are `KindUsage`. `ExecFrames` returns a final error frame as `KindStream`. Concurrent checkpoints step to the next id. A failed `Rewind` says the sandbox was deleted. New `ErrUsage`. | | `v0.1.0` | First release. | Install the latest with `go get github.com/kosa8ai/kosa8-go@v0.1.2`. The full list is in the module's `CHANGELOG.md`. **Next** [Go errors and troubleshooting](https://docs.kosa8.com/guides/go-errors/): every error, when it happens and what to do. [Go API reference](https://docs.kosa8.com/guides/go-api/): every export with its signature. [SDKs in five languages](https://docs.kosa8.com/guides/sdks/): the same calls from other languages. --- Source: https://docs.kosa8.com/guides/go-guide/ # Go errors and troubleshooting What can go wrong when you drive kosa8 from Go, what the SDK hands back when it does, and what to do about it. The messages on this page are real: they were printed by the programs below, against a kosa8 0.9.36 daemon. For the calls themselves, see the [developer guide](https://docs.kosa8.com/guides/go-guide/) and the [API reference](https://docs.kosa8.com/guides/go-api/). **How this page was tested** The two programs on this page were run against a real kosa8 daemon 0.9.36 on an Apple silicon Mac, with `github.com/kosa8ai/kosa8-go v0.1.2` from the Go module proxy and go1.26.9 darwin/arm64, and their output is pasted unchanged (the first had also been run on v0.1.0; the second printed identical output on both). The `Exec`, `ExecFrames`, `WriteStdin` and capture messages quoted below come from the [developer guide](https://docs.kosa8.com/guides/go-guide/)'s programs. Everything was run on v0.1.1; the sandbox-free first program and the usage messages that v0.1.2 reworded were run again on v0.1.2. The sandbox they create was deleted. No org policy was installed and no remote worker was used, so the policy, approval and authentication (401) errors were **not** produced live; what this page says about them comes from reading the SDK's source, and is marked. The locked-screen message is quoted from our tests of the same daemon version for the [JavaScript pages](https://docs.kosa8.com/guides/js-troubleshooting/#locked); it was not reproduced for this page. ## What this is, in plain words When kosa8 cannot do what your program asked, the SDK returns an **error** instead of a result. In Go, that is the second value every call returns. Each error says *what kind* of problem it is (kosa8 is not running, the sandbox does not exist, a company rule forbids it, it took too long), and, when kosa8 itself refused, it carries kosa8's own explanation and its advice about what to do next. One thing is *not* an error: a command that runs and fails. If your command exits with code 1, you get a normal result with `ExitCode` 1 and `err` is nil. An error means the command could not be run at all. ## The error type Every error this package creates is a `*kosa8.Error`. Two kinds of failure come back as they are, without that wrapper: - a **cancelled** context: `context.Canceled` (a context whose *deadline* passed is a `*kosa8.Error` of kind `timeout`, see below); - failures of the **checkpoint store** itself: a file that cannot be read or written, the lock that cannot be taken (`lock the checkpoint store: …`), or any error from a `CheckpointStore` you supplied. | Field | Set when | Meaning | |---|---|---| | `Kind` | always | Which kind of failure; table below. | | `Message` | always | What `Error()` returns. For a daemon refusal: `" ()"`, the way the CLI prints it. | | `Status` | the daemon answered with HTTP 400 or above | The HTTP status. 0 otherwise, including for stream errors. | | `DaemonMessage` | daemon refusals, stream error frames | The daemon's own `error` text, verbatim. | | `Remediation` | daemon refusals that include advice | What the daemon says to do. Can be empty (a bad egress value had none). | | `Method`, `Path` | HTTP and connection errors, most stream errors | The request, such as `POST /kosa8/v1/sandboxes/x/run`. | | `Body` | daemon refusals | The decoded response body (`map[string]any` or a string). | | `Approval` | `KindApprovalRequired` | The daemon's refusal object (it has a `why` key, and whatever else the daemon sends). | | `Stdout`, `Stderr` | `KindStream`, `KindTimeout` from `Run`/`Exec`/`Attach` | Output produced before the failure. The returned `ExecResult` holds the same. | | `Err` | connection errors, timeouts, decode errors, a failed rewind | The underlying cause; `Unwrap` returns it, so `errors.Is`/`As` see through. | ### Kinds and sentinels | `Kind` | Sentinel | When | |---|---|---| | `connection` | `ErrConnection` | The daemon could not be reached, or the answer could not be read. | | `bad_request` | `ErrBadRequest` | HTTP 400: the daemon rejected the arguments. | | `auth` | `ErrAuth` | HTTP 401: a remote worker rejected the token. *From the source.* | | `policy` | `ErrPolicy` | HTTP 403: the org or label policy refused. | | `approval_required` | `ErrApprovalRequired` | HTTP 403 whose body has an `approval` object with `why`: a signed approval would allow it. *From the source.* | | `not_found` | `ErrNotFound` | HTTP 404: no such sandbox or snapshot. | | `conflict` | `ErrConflict` | HTTP 409: name or id taken, sandbox not running. | | `unsupported` | `ErrUnsupported` | HTTP 501: this daemon or platform cannot do it. | | `api` | `ErrAPI` | Any other HTTP error status (500, 503, …), or an answer that could not be decoded. | | `stream` | `ErrStream` | A `Run`/`Exec`/`Attach` stream ended with an error frame, a broken frame, or no final frame. | | `timeout` | `ErrTimeout` | The context's deadline (or `RunOptions.Timeout`) passed. | | `checkpoint` | `ErrCheckpoint` | Client-side checkpoint bookkeeping: bad name, nothing to rewind to, unknown checkpoint, or a rewind whose restore step failed. | | `usage` | `ErrUsage` (new in v0.1.1) | Bad arguments caught before sending. | `(*Error).Is` matches a target `*Error` whose `Message` is empty (a sentinel, or `&kosa8.Error{Kind: k}`) by `Kind`, with two extra rules: - `errors.Is(err, kosa8.ErrPolicy)` is also true for `approval_required`: an approval refusal is a policy refusal. - `errors.Is(err, kosa8.ErrAPI)` is true for every HTTP kind: `api`, `bad_request`, `auth`, `policy`, `approval_required`, `not_found`, `conflict`, `unsupported`. It is false for `stream`, `timeout`, `connection`, `checkpoint` and `usage`, even when the stream error was the daemon's own words. So test the specific kinds first. A handler that covers everything: ``` res, err := sb.Run(ctx, image, args, nil) switch { case err == nil: // It ran. res.ExitCode says how it went; non-zero is not an error. case errors.Is(err, context.Canceled): return err // we were told to stop case errors.Is(err, kosa8.ErrTimeout): log.Printf("too slow; output so far: %q", res.Stdout()) case errors.Is(err, kosa8.ErrApprovalRequired): e, _ := kosa8.AsError(err) log.Printf("needs approval: %v", e.Approval["why"]) // retry later with RunOptions{Approval: id} case errors.Is(err, kosa8.ErrPolicy): return err // a rule says no; do not retry case errors.Is(err, kosa8.ErrNotFound): // the sandbox is gone: recreate it, or give up case errors.Is(err, kosa8.ErrConflict): // e.g. "sandbox is not running": sb.Start(ctx), then retry once case errors.Is(err, kosa8.ErrStream): // the command could not start (no image, no such executable, no container) case errors.Is(err, kosa8.ErrConnection): // kosa8 is not running: tell the user to run `kosa8 daemon start` case errors.Is(err, kosa8.ErrUsage): panic(err) // a bug in our own arguments default: return err } ``` For logs, `err.Error()` is already the full sentence with the daemon's advice. `kosa8.AsError(err)` is `errors.As` into `*kosa8.Error`, for reading the fields; `var e *kosa8.Error; errors.As(err, &e)` does the same. ## Every error, provoked The first program needs no sandbox. Its `show` function prints the kind, the status, which sentinels match, and the fields: ``` package main import ( "context" "errors" "fmt" "strings" "time" kosa8 "github.com/kosa8ai/kosa8-go" ) func show(label string, err error) { fmt.Printf("== %s\n", label) if err == nil { fmt.Println(" nil") return } e, ok := kosa8.AsError(err) if !ok { fmt.Printf(" not a *kosa8.Error: %T %v (Canceled=%v)\n", err, err, errors.Is(err, context.Canceled)) return } var is []string for n, s := range map[string]error{"ErrConnection": kosa8.ErrConnection, "ErrAPI": kosa8.ErrAPI, "ErrBadRequest": kosa8.ErrBadRequest, "ErrPolicy": kosa8.ErrPolicy, "ErrNotFound": kosa8.ErrNotFound, "ErrConflict": kosa8.ErrConflict, "ErrStream": kosa8.ErrStream, "ErrCheckpoint": kosa8.ErrCheckpoint, "ErrTimeout": kosa8.ErrTimeout, "ErrUnsupported": kosa8.ErrUnsupported, "ErrAuth": kosa8.ErrAuth, "ErrUsage": kosa8.ErrUsage} { if errors.Is(err, s) { is = append(is, n) } } fmt.Printf(" Kind=%s Status=%d errors.Is: %s\n Message: %s\n", e.Kind, e.Status, strings.Join(sorted(is), ", "), e.Message) if e.DaemonMessage != "" { fmt.Printf(" DaemonMessage: %s\n Remediation: %s\n", e.DaemonMessage, e.Remediation) } if e.Method != "" { fmt.Printf(" %s %s\n", e.Method, e.Path) } if e.Err != nil { fmt.Printf(" Err: %T\n", e.Err) } } func sorted(a []string) []string { for i := range a { for j := i + 1; j < len(a); j++ { if a[j] < a[i] { a[i], a[j] = a[j], a[i] } } } return a } func main() { ctx := context.Background() gone, _ := kosa8.New(kosa8.Options{SocketPath: "/tmp/k8-nowhere.sock"}) _, err := gone.Version(ctx) show("daemon not reachable", err) k, _ := kosa8.New(kosa8.Options{}) _, err = k.CreateSandbox(ctx, kosa8.CreateOptions{Egress: "any", EgressAllow: []string{"pypi.org"}}) show("EgressAllow with Egress any", err) _, err = k.Explore(ctx, "x", []string{"true"}, kosa8.ExploreOptions{}) show("Explore with one variant", err) _, err = k.CreateSandbox(ctx, kosa8.CreateOptions{Name: "doc-go-g-bad", Egress: "sometimes"}) show("bad egress value", err) _, err = k.CreateSandbox(ctx, kosa8.CreateOptions{Name: "doc-go-g-bad", Mounts: []string{"/tmp"}}) show("a mount with no guest path", err) _, err = k.StopSandbox(ctx, "doc-go-g-nope") show("StopSandbox on a missing sandbox", err) _, err = k.Run(ctx, "doc-go-g-nope", "alpine:latest", []string{"true"}, nil) show("Run on a missing sandbox", err) _, err = k.DeleteSnapshot(ctx, "doc-go-g-nope-snap", kosa8.SnapshotDeleteOptions{}) show("DeleteSnapshot missing", err) _, err = k.RestoreSnapshot(ctx, "doc-go-g-nope-snap", kosa8.RestoreOptions{}) show("RestoreSnapshot missing", err) _, err = k.DeleteSandbox(ctx, "doc-go-g-nope", kosa8.DeleteOptions{}) show("DeleteSandbox missing", err) _, err = k.Rewind(ctx, "doc-go-g-never", "") show("Rewind with no checkpoints", err) _, err = k.Checkpoint(ctx, "../etc", "x") show("Checkpoint bad name", err) tctx, cancel := context.WithTimeout(ctx, time.Nanosecond) defer cancel() time.Sleep(time.Millisecond) _, err = k.Health(tctx) show("expired deadline", err) cctx, cancel2 := context.WithCancel(ctx) cancel2() _, err = k.Health(cctx) show("cancelled context", err) } ``` ``` $ go run . == daemon not reachable Kind=connection Status=0 errors.Is: ErrConnection Message: kosa8 daemon unreachable at unix:///tmp/k8-nowhere.sock: Get "http://kosa8/kosa8/v1/health": dial unix /tmp/k8-nowhere.sock: connect: no such file or directory (is kosa8 installed and its daemon running? start it with `kosa8 daemon start`) GET /kosa8/v1/health Err: *url.Error == EgressAllow with Egress any Kind=usage Status=0 errors.Is: ErrUsage Message: EgressAllow already limits the network to the hosts it lists; do not also set Egress == Explore with one variant Kind=usage Status=0 errors.Is: ErrUsage Message: give at least two variants; with one there is nothing to compare == bad egress value Kind=bad_request Status=400 errors.Is: ErrAPI, ErrBadRequest Message: egress policy "sometimes": want any, deny, fakenet, or allow:HOST[,HOST...] DaemonMessage: egress policy "sometimes": want any, deny, fakenet, or allow:HOST[,HOST...] Remediation: POST /kosa8/v1/sandboxes == a mount with no guest path Kind=usage Status=0 errors.Is: ErrUsage Message: mount "/tmp": want host:guest or host:guest:ro == StopSandbox on a missing sandbox Kind=not_found Status=404 errors.Is: ErrAPI, ErrNotFound Message: no such sandbox: doc-go-g-nope (list sandboxes with `kosa8 sandbox ls`) DaemonMessage: no such sandbox: doc-go-g-nope Remediation: list sandboxes with `kosa8 sandbox ls` POST /kosa8/v1/sandboxes/doc-go-g-nope/down == Run on a missing sandbox Kind=not_found Status=404 errors.Is: ErrAPI, ErrNotFound Message: no such sandbox: doc-go-g-nope (list sandboxes with `kosa8 sandbox ls`) DaemonMessage: no such sandbox: doc-go-g-nope Remediation: list sandboxes with `kosa8 sandbox ls` POST /kosa8/v1/sandboxes/doc-go-g-nope/run == DeleteSnapshot missing Kind=not_found Status=404 errors.Is: ErrAPI, ErrNotFound Message: no such snapshot: doc-go-g-nope-snap (list snapshots with `kosa8 snapshot ls`) DaemonMessage: no such snapshot: doc-go-g-nope-snap Remediation: list snapshots with `kosa8 snapshot ls` DELETE /kosa8/v1/snapshots/doc-go-g-nope-snap == RestoreSnapshot missing Kind=api Status=500 errors.Is: ErrAPI Message: no such snapshot: doc-go-g-nope-snap (list snapshots with `kosa8 snapshot ls`) DaemonMessage: no such snapshot: doc-go-g-nope-snap Remediation: list snapshots with `kosa8 snapshot ls` POST /kosa8/v1/snapshots/doc-go-g-nope-snap/restore == DeleteSandbox missing Kind=not_found Status=404 errors.Is: ErrAPI, ErrNotFound Message: no such sandbox: doc-go-g-nope (list sandboxes with `kosa8 sandbox ls`) DaemonMessage: no such sandbox: doc-go-g-nope Remediation: list sandboxes with `kosa8 sandbox ls` DELETE /kosa8/v1/sandboxes/doc-go-g-nope == Rewind with no checkpoints Kind=checkpoint Status=0 errors.Is: ErrCheckpoint Message: no checkpoints for "doc-go-g-never" — take one with Checkpoint or `kosa8 checkpoint doc-go-g-never` == Checkpoint bad name Kind=checkpoint Status=0 errors.Is: ErrCheckpoint Message: invalid sandbox name "../etc" == expired deadline Kind=timeout Status=0 errors.Is: ErrTimeout Message: GET /kosa8/v1/health timed out talking to unix:///Users/dhruvit/.kosa8/run/kosa8.sock GET /kosa8/v1/health Err: *url.Error == cancelled context not a *kosa8.Error: *errors.errorString context canceled (Canceled=true) ``` An earlier version of this program also created a sandbox named `"bad name!"`, expecting a refusal. The daemon accepted the name (on both SDK versions), so that case was dropped and the sandbox deleted. Worth knowing from this output: - **A missing snapshot is a 404 for `DeleteSnapshot` but a 500 for `RestoreSnapshot`** (kind `api`, not `not_found`), with the same message. Code that restores should not rely on `ErrNotFound` alone; check `DaemonMessage` or `ListSnapshots` first. - **A malformed mount is caught by the SDK** (`KindUsage`). In v0.1.0 every mount, well-formed or not, reached the daemon as a string and failed with a 500 `mount has no guest path`; v0.1.1 sends the objects the daemon takes, and mounts work. The second program creates one sandbox and gets it into trouble: ``` func show(label string, err error) { fmt.Printf("== %s\n", label) e, ok := kosa8.AsError(err) if !ok { fmt.Printf(" %T %v\n", err, err) return } fmt.Printf(" Kind=%s Status=%d ErrAPI=%v\n Message: %s\n", e.Kind, e.Status, errors.Is(err, kosa8.ErrAPI), e.Message) if e.Stdout != "" || e.Stderr != "" { fmt.Printf(" Stdout=%q Stderr=%q\n", e.Stdout, e.Stderr) } } func main() { ctx := context.Background() k, _ := kosa8.New(kosa8.Options{}) const name = "doc-go-g-errs" if _, err := k.CreateSandbox(ctx, kosa8.CreateOptions{Name: name, Egress: "deny"}); err != nil { show("create", err) os.Exit(1) } defer k.DeleteSandbox(context.Background(), name, kosa8.DeleteOptions{}) _, err := k.CreateSandbox(ctx, kosa8.CreateOptions{Name: name}) show("create the same name twice", err) _, err = k.Run(ctx, name, "alpine:latest", []string{"true"}, nil) show("Run before PullIntoSandbox", err) _, err = k.PullIntoSandbox(ctx, name, "docker.io/library/no-such-image-kosa8:v0") show("pull an image that does not exist", err) _, _ = k.PullIntoSandbox(ctx, name, "alpine:latest") _, err = k.Run(ctx, name, "alpine:latest", []string{"no-such-binary"}, nil) show("Run a command that does not exist", err) _, err = k.Exec(ctx, name, "k99-nope", []string{"true"}, nil) show("Exec in a container that does not exist", err) _, err = k.Attach(ctx, name, "k99-nope", nil) show("Attach to a container that does not exist", err) _, err = k.Fork(ctx, name, 1, kosa8.ForkOptions{Label: "experiment"}) show("Fork with a free-text Label", err) _, err = k.Snapshot(ctx, name, kosa8.SnapshotOptions{ID: "doc-go-g-errs-s1"}) show("first snapshot", err) _, err = k.Snapshot(ctx, name, kosa8.SnapshotOptions{ID: "doc-go-g-errs-s1"}) show("snapshot id taken twice", err) _, err = k.DeleteSnapshot(ctx, "doc-go-g-errs-s1", kosa8.SnapshotDeleteOptions{}) show("delete snapshot", err) _, err = k.StopSandbox(ctx, name) _, err = k.Run(ctx, name, "alpine:latest", []string{"true"}, nil) show("Run on a stopped sandbox", err) _, err = k.Snapshot(ctx, name, kosa8.SnapshotOptions{}) show("Snapshot a stopped sandbox", err) } ``` ``` $ go run . == create the same name twice Kind=conflict Status=409 ErrAPI=true Message: sandbox doc-go-g-errs already exists (choose another name, or remove the existing sandbox with `kosa8 sandbox rm NAME` (`kosa8 sandbox ls` lists them)) == Run before PullIntoSandbox Kind=stream Status=0 ErrAPI=false Message: image "docker.io/library/alpine:latest" not found in engine: image "docker.io/library/alpine:latest": not found == pull an image that does not exist Kind=api Status=500 ErrAPI=true Message: pull : manifest: unauthorized after token refresh (check the image reference and network) == Run a command that does not exist Kind=stream Status=0 ErrAPI=false Message: failed to create shim task: OCI runtime create failed: executable file `no-such-binary` not found in $PATH: No such file or directory == Exec in a container that does not exist Kind=stream Status=0 ErrAPI=false Message: no such container: k99-nope == Attach to a container that does not exist Kind=stream Status=0 ErrAPI=false Message: no such container: k99-nope == Fork with a free-text Label Kind=policy Status=403 ErrAPI=true Message: refused by label policy: label "experiment": level "EXPERIMENT" is not defined (defined: UNCLASSIFIED, CUI); an org policy defines levels under "labels" (a fork keeps its parent's label or raises it; see `kosa8 label ls`) == first snapshot == snapshot id taken twice Kind=conflict Status=409 ErrAPI=true Message: snapshot already exists: doc-go-g-errs-s1 (choose another id, or delete the existing snapshot first) == delete snapshot == Run on a stopped sandbox Kind=conflict Status=409 ErrAPI=true Message: sandbox is not running: doc-go-g-errs (start it with `kosa8 sandbox up doc-go-g-errs`) == Snapshot a stopped sandbox Kind=api Status=500 ErrAPI=true Message: sandbox is not running: doc-go-g-errs (sandbox must be running) ``` (` ` is how this `show` prints a call that succeeded.) Note the same condition, "sandbox is not running", is a 409 for `Run` and a 500 for `Snapshot`; `errors.Is(err, kosa8.ErrConflict)` only catches the first. ## Each kind: when, and what to do ### connection: the daemon cannot be reached **When.** The socket file does not exist, nothing is listening on it, a remote worker does not answer, or the answer was cut off before it could be read. The message names the endpoint, and adds `(is kosa8 installed and its daemon running? …)` when the cause looks like "not running". `Err` is the `*url.Error` from `net/http`. **What to do.** `kosa8 daemon start`, then `kosa8 version`. Check that your program and the daemon agree on `HOME`, since the socket is `$HOME/.kosa8/run/kosa8.sock`. If `KOSA8_HOST` is set in your shell, the SDK goes there instead; `k.Endpoint().Target()` prints where it is going. Retrying is reasonable only right after a daemon restart. ### usage: caught before sending **When.** From `New`: an `Actor` other than `human`, `agent`, `tool`; a `KOSA8_HOST`/`Host` with an unsupported scheme or no host name; plain `http://` to a host that is not loopback without `Insecure` or `KOSA8_INSECURE=1`. From calls: `EgressAllow` with an `Egress` other than `""`/`"deny"`; `Explore` with fewer than two variants, a fork that returned fewer branches than variants, or a `Keep` that names no branch (that one also returns the result, with every branch kept); a malformed or relative `Mounts` entry; a `Capture` with no `Path`: ``` mount "hostdir:/work": both paths must be absolute (use filepath.Abs for the host side) Capture.Path is required: the host file the capture is written to ``` New in v0.1.1, two mistakes that used to pass silently: ``` Exec does not take Env: the container keeps the environment it was started with. Set it at StartInteractive (InteractiveOptions.Env), or wrap the command: []string{"env", "K=V", "cmd"} stdin of container k8-e5df7b9b in doc-go-g-run was already sent and is closed: the daemon treats one WriteStdin body as the whole of stdin. Pass an io.Pipe reader to stream input ``` The first is `Exec` with `RunOptions.Env` (the daemon's exec takes no environment; use `[]string{"env", "K=V", "cmd", …}`, or start the container with `StartInteractive` and its `InteractiveOptions.Env`). The second is a second `WriteStdin` to the same container from the same `Client`. Other real messages are in the [guide](https://docs.kosa8.com/guides/go-guide/#connecting) and the output above. **What to do.** Fix the arguments; these never succeed on retry. Test with `errors.Is(err, kosa8.ErrUsage)` (v0.1.0 had no sentinel; `e.Kind == kosa8.KindUsage` works in both). ### bad_request (400) **When.** The daemon rejected a value, such as an unknown egress policy. **What to do.** Fix the value; read `DaemonMessage`, which says what it wanted. Not retryable. ### auth (401) **When** (from the source; not produced live). A remote worker rejected or did not get the bearer token. The common cause is setting `Options.Host` in code while the token is only in `KOSA8_TOKEN`: the env token is used only with an env host. **What to do.** Pass `Token` explicitly, or set both `KOSA8_HOST` and `KOSA8_TOKEN` and leave `Host` empty. A unix socket never sends a token. ### policy and approval_required (403) **When.** A rule refused the action. Live, we got one from the label policy, by passing a free text `ForkOptions.Label` (above): `Label` on forks and restores is a classification level, not a description. The other 403s (org policy rules on registries, egress, mounts, and so on, and refusals that a signed approval would lift) were not produced live, since no policy was installed. From the source: a 403 whose body has an `approval` object with a `why` key becomes `approval_required`, with that object in `e.Approval`; anything else is `policy`. **What to do.** For `policy`, do not retry: change what you ask for, or ask whoever owns the policy (`kosa8 policy show`, [Company rules](https://docs.kosa8.com/guides/governance/)). For `approval_required`, show the user `e.Approval["why"]` and the remediation, get the approval recorded, then repeat the call with its id in `Call{Approval: id}` or the options' `Approval` field. ``` if errors.Is(err, kosa8.ErrApprovalRequired) { e, _ := kosa8.AsError(err) fmt.Println("needs approval:", e.Approval["why"]) fmt.Println("then:", e.Remediation) return err } ``` ### not_found (404) **When.** No sandbox or snapshot by that name, for most calls. Exceptions seen live: `RestoreSnapshot` of a missing snapshot is a 500 (`api`); and `Exec`/`Attach` on a missing *container* is a `stream` error. `GetSandbox` never returns not_found: a missing sandbox is `nil, nil`. **What to do.** In clean-up code, treat it as success (`err != nil && !errors.Is(err, kosa8.ErrNotFound)`). Elsewhere, it usually means something else deleted the sandbox, or a rewind failed halfway (see [below]). ### conflict (409) **When.** A sandbox name or snapshot id is taken; the sandbox is stopped and you asked it to run something. `Checkpoint` handles its own case: when two checkpoints of one sandbox race for the same id, the loser's snapshot gets `snapshot already exists` and, from v0.1.1, moves to the next number (up to 10 tries), so you do not see it (see [Goroutines](https://docs.kosa8.com/guides/go-guide/#goroutines)). Any other 409 from that snapshot is returned. In v0.1.0 this race came back to you as: ``` snapshot already exists: doc-go-g-tick-ckpt-2 (choose another id, or delete the existing snapshot first) ``` **What to do.** Pick another name, delete the old one, or `sb.Start(ctx)` and retry. ### unsupported (501) **When** (from the source). The daemon does not implement the call on this platform or version. **What to do.** Upgrade kosa8 (`kosa8 version` shows both sides), or use another way to get the same result. ### api: everything else **When.** Any other HTTP error, mostly 500s with the daemon's own explanation: the guest did not boot in time, a pull failed, a restore failed, a snapshot of a stopped sandbox. From kosa8 0.9.36 a restore refused because a Mac's screen is locked is a 503. Also used when a successful answer could not be decoded (`decode …`, with `Err` set). **What to do.** Read `Message`; the daemon's remediation is in it. Many of these are about the machine (memory, disk, a locked screen), so a retry after fixing that can work. See the table below. ### stream: the command could not run **When.** `Run`, `Exec` and `Attach` answer with a stream of frames, so the HTTP status is already 200 by the time the daemon finds out the command cannot start. It then sends a final frame with an `error`, and the SDK returns `KindStream` with `Status` 0. Seen live: image not pulled into the sandbox, an executable that does not exist, a container id that does not exist. The SDK also returns `KindStream` for a frame it cannot decode, and for a stream that ends without a final frame (`stream ended unexpectedly (no final frame)`), which is what a daemon restart in the middle of a run looks like. `Stdout`/`Stderr` on the error hold any output that arrived first. **What to do.** Pull the image with `sb.Pull`; check the command's spelling (a missing executable is an error, not exit 127); check the container id with `sb.Containers`. Remember `errors.Is(err, kosa8.ErrAPI)` is false for these. `ExecFrames` passes the error frame to your function (`f.Done` and `f.Error` set) and then returns the same `KindStream` error, from v0.1.1 (v0.1.0 returned nil): ``` frame done=true error="no such container: k99-nope" ExecFrames on a missing container: true no such container: k99-nope ``` ### timeout, and cancellation **When.** The context's deadline passed, or `RunOptions.Timeout` did. Before the answer started: ` timed out talking to `, with `Err` wrapping `context.DeadlineExceeded`. During a stream: `… timed out mid-stream`, with the output so far. A *cancelled* context returns the plain `context.Canceled`, not a `*kosa8.Error`. **What to do.** Either way, the work inside the sandbox **keeps running**: only your program stopped waiting. In our test, two `sleep 30` containers whose `Run` had timed out or been cancelled were still listed as running afterwards. Stop or delete the sandbox if that matters, or bound the command itself (`timeout 30 …`). Give long operations (create, fork, explore) generous deadlines: on our busy machine a create took 12 to 14 s, the quickstart's took 21 s, and an explore with three variants took 87.5 s. ### checkpoint: bookkeeping, and a failed rewind **When.** All before anything is sent, except the last: - `invalid sandbox name "../etc"`: empty, `.`, `..`, a leading dot, or a `/` or `\` in the name. - `no checkpoints for "NAME" — take one with Checkpoint or `kosa8 checkpoint NAME: `Rewind(ctx, name, "")` with an empty stack. - `no checkpoint "step 1" for sandbox "NAME"`: `to` matches no id or label, for example one dropped by an earlier rewind. - `restore failed and NAME no longer exists (it was deleted to free its name for the restore): … — the checkpoint is intact; recover with RestoreSnapshot("ID", RestoreOptions{Name: "NAME"})`: the rewind deleted the sandbox, then could not restore it. `Err` is the daemon's error, so `errors.Is(err, kosa8.ErrAPI)` is also true here when the daemon answered. (v0.1.0 said "is now stopped", which was wrong.) *From the source; not produced live.* **What to do.** For the last one, fix the cause (usually a locked Mac screen, see below), then run exactly the call in the message: ``` if errors.Is(err, kosa8.ErrCheckpoint) && errors.Is(err, kosa8.ErrAPI) { // Rewind removed the sandbox and could not restore it. After fixing the cause: _, err = k.RestoreSnapshot(ctx, target.ID, kosa8.RestoreOptions{Name: sandbox}) } ``` Here `target` is the checkpoint you rewound to (from `ListCheckpoints` before the call). The checkpoint records are left as they were, so after a successful manual restore, later checkpoints are still in the stack. ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| | `kosa8 daemon unreachable at unix://…: … no such file or directory` | The daemon is not running, or runs under another `HOME`. | `kosa8 daemon start`; run your program with the same `HOME`. | | Connection error, `bind: invalid argument` in the daemon log (macOS) | Socket path over 104 bytes: `HOME` is a deep temporary folder (CI, test harnesses). | Use a short `HOME` or a symlink to it, for the daemon and your program. See the [JavaScript notes](https://docs.kosa8.com/guides/js-troubleshooting/#socket). | | `guest not ready on vsock port 1024 after 25s (run `kosa8 doctor`; check ~/.kosa8/log/)` from `CreateSandbox` | The VM did not boot in time; in our tests, low free memory with several other sandboxes running. | Delete sandboxes you do not need, give new ones less `MemMB`, and retry after a pause ([recipe](https://docs.kosa8.com/guides/go-guide/#recipes)). Nothing was left behind by the failed create. | | `image "…" not found in engine` (`KindStream`) | The image was not pulled into *this* sandbox. `k.Pull` pulls into the host, not a sandbox. | `sb.Pull(ctx, image)` first. Forks inherit their parent's images. | | `pull : manifest: unauthorized after token refresh` | The image does not exist, or is private. | Check the reference (name and tag) with `kosa8 pull` on the host. | | `executable file `x` not found in $PATH` | The program is not in the image. It is an error, not exit 127. | Use a full path, another image, or `sh -c` for shell builtins. | | `no such container: k…` | Wrong id, or the container exited, or the sandbox was restarted (containers do not survive a stop). | `sb.Containers(ctx)`; start a new one. | | `Exec does not take Env` (`KindUsage`) | The daemon's exec has no environment; v0.1.0 silently dropped it. | Wrap the command: `[]string{"env", "FOO=bar", "sh", "-c", "…"}`. | | `mount "…": want host:guest or host:guest:ro`, or `both paths must be absolute` | A `Mounts` entry without both parts, with a suffix other than `ro`/`rw`, or (v0.1.2) with a relative path. | `"/abs/host/dir:/guest/dir:ro"`. (On v0.1.0, every mount failed with `mount has no guest path`; upgrade.) | | `capture: open …: is a directory` (500) | `CaptureOptions.Path` is the pcapng file to write, not a directory. | Pass a file path, such as `dir/traffic.pcapng`. | | `stdin of container … was already sent and is closed` | A second `WriteStdin`: one body is the whole of stdin. | One call per container, with an `io.Pipe` reader to feed input over time. | | `refused by label policy: label "…": level "…" is not defined` | `ForkOptions.Label`/`RestoreOptions.Label` is a classification level. | Leave it empty, or use a level from `kosa8 label ls`. Use `CreateOptions.Labels` for tags. | | `sandbox … already exists` (409) | The name is taken, often by a sandbox an earlier run did not delete. | Delete it, or use unique names; see the clean-up recipe. | | `snapshot already exists: NAME-ckpt-N` from `Checkpoint` | v0.1.0: two checkpoints of one sandbox at the same time. | Upgrade to v0.1.1, which steps to the next id. | | `sandbox is not running` (409 or 500) | The sandbox was stopped, by you or by a daemon restart. | `sb.Start(ctx)`; running containers are gone after a stop. | | `macOS blocks restoring a VM while the screen is locked; unlock and retry` (503), from `Rewind`, `Fork`, `Explore`, `RestoreSnapshot` | Apple's Virtualization framework cannot restore a saved VM while the Mac is locked. Saving still works. | Keep the Mac unlocked during jobs that restore, or use Linux. After a failed rewind, run the `RestoreSnapshot` call from its message. Details on the [JavaScript troubleshooting page](https://docs.kosa8.com/guides/js-troubleshooting/#locked). | | `… timed out mid-stream`, but the work goes on | Timeouts and cancellation stop the client only. | Stop or delete the sandbox, or bound the command with `timeout`. | | `kosa8 rewind` does not see checkpoints taken in Go | The client uses a private store (`Options.Checkpoints`), or a different `HOME`. | Use the default store; check `~/.kosa8/checkpoints.json`. | | 401 from a remote worker though `KOSA8_TOKEN` is set | `Options.Host` was set in code; the env token is only used with `KOSA8_HOST`. | Pass `Token` as well. | | `refusing to talk to HOST over plain http` | Plain HTTP to a non-loopback worker. | Use `https://`, or `Insecure: true` / `KOSA8_INSECURE=1` on a private link. | | Leftover sandboxes after a crash or Ctrl-C | The program exited without running its deferred deletes (`os.Exit`, `log.Fatal`, a signal). | `signal.NotifyContext` and defers with a fresh context, as in the [recipe](https://docs.kosa8.com/guides/go-guide/#recipes); clean up with `kosa8 sandbox ls` / `kosa8 snapshot ls`. | **Still stuck?** `kosa8 doctor` checks the installation, and `~/.kosa8/log/kosa8d.log` has the daemon's side of every refusal. [When something is wrong](https://docs.kosa8.com/guides/self-repair/) covers the daemon itself. The [developer guide](https://docs.kosa8.com/guides/go-guide/) shows each call working. --- Source: https://docs.kosa8.com/guides/go-errors/ # Go API reference Every export of `github.com/kosa8ai/kosa8-go` v0.1.2, package `kosa8`. Signatures are copied from the source, and the list of names on this page was checked against `go doc -all`. Paths are the daemon's native API; `…` stands for `/kosa8/v1/sandboxes/{sandbox}`. [`Client`New, Options, and every method with its endpoint.] [`Sandbox`The handle bound to one sandbox.] [`Option types`What methods take.] [`Result types`What methods return.] [`Checkpoint stores`Where checkpoint stacks live.] [`Endpoint and frames`Lower-level pieces and constants.] [`Errors`Error, Kind, sentinels, AsError.] ## Conventions - Every method that talks to the daemon takes a `context.Context` first. Its deadline or cancellation ends the HTTP request (not a process already running in the sandbox). - `call ...Call` is an optional trailing `kosa8.Call{Approval: id}`; only the first is used. Methods that take an options struct carry `Approval` in it instead. The approval is sent as the `X-Kosa8-Approval` header. - `Info` is `map[string]any`: the daemon's JSON object, unchanged, with JSON numbers as `float64`. - Every error returned by this package is a `*kosa8.Error`, except a cancelled (not timed-out) context, which comes back as `context.Canceled`, and failures from a custom `CheckpointStore` or the checkpoint file, which come back as they are. ## `Client` ``` type Client struct { // contains filtered or unexported fields } func New(o Options) (*Client, error) func (c *Client) Endpoint() Endpoint ``` `New` resolves the endpoint (see [`ResolveEndpoint`]), checks `Actor`, builds the HTTP client and picks the checkpoint store. It sends nothing. It fails with a `KindUsage` error for a refused `KOSA8_HOST` or host, or an actor other than `human`, `agent` or `tool`. A `*Client` is safe for concurrent use by multiple goroutines. `Endpoint` reports where requests go. ### `Options` ``` type Options struct { SocketPath string // default ~/.kosa8/run/kosa8.sock Host string // a remote worker; default $KOSA8_HOST Token string // bearer token for Host; default $KOSA8_TOKEN when Host came from the env Insecure bool // allow plain http to a non-loopback worker Actor string // human | agent | tool (default tool) AgentToken string // default $KOSA8_AGENT_TOKEN Headers map[string]string HTTPClient *http.Client // overrides the transport entirely // Checkpoints is where checkpoint stacks live. Default: the CLI's own // ~/.kosa8/checkpoints.json, so `kosa8 rewind` and this SDK share them. Checkpoints CheckpointStore Getenv func(string) string // for tests; default os.Getenv } ``` | Field | Default | Effect | |---|---|---| | `SocketPath` | `DefaultSocketPath()` | The daemon's unix socket. Used only when no host is set, either here or in `KOSA8_HOST`. | | `Host` | `$KOSA8_HOST` | A worker address. Forms and rules under [`ResolveEndpoint`]. | | `Token` | `$KOSA8_TOKEN`, only when the host came from `KOSA8_HOST` | Sent as `Authorization: Bearer …`, to a URL endpoint only, never over a unix socket. | | `Insecure` | false (or `KOSA8_INSECURE=1`) | Allows plain `http://` to a host that is not loopback. | | `Actor` | `"tool"` | Sent as `X-Kosa8-Actor` on every request; recorded in the audit log. | | `AgentToken` | `$KOSA8_AGENT_TOKEN` | Sent as `X-Kosa8-Agent-Token` when non-empty. | | `Headers` | none | Set on every request after `Accept` and `X-Kosa8-Actor` (so they can replace those) and before `Content-Type`, `X-Kosa8-Approval`, `X-Kosa8-Agent-Token` and `Authorization` (which win when the SDK sets them). | | `HTTPClient` | an `http.Client` with compression off, dialling the socket for a local endpoint | Replaces the SDK's client entirely. For a local endpoint, requests go to `http://kosa8/…` and your client must dial the socket itself. | | `Checkpoints` | `NewFileCheckpointStore("")` | The checkpoint store. See [Checkpoint stores]. | | `Getenv` | `os.Getenv` | Where `KOSA8_HOST`, `KOSA8_TOKEN`, `KOSA8_INSECURE` and `KOSA8_AGENT_TOKEN` are read from. | ### Daemon | Method | Returns | HTTP | |---|---|---| | `func (c *Client) Health(ctx context.Context) (Info, error)` | Keys `status`, `version`, `commit`, `go`, `uptime_seconds`, `phase` | `GET /kosa8/v1/health` | | `func (c *Client) Version(ctx context.Context) (string, error)` | The daemon's `version` (`""` if absent) | `GET /kosa8/v1/health` | | `func (c *Client) Pull(ctx context.Context, ref string, call ...Call) (Info, error)` | The daemon's answer | `POST /kosa8/v1/pull` `{"ref"}`: into the host's shared engine, not a sandbox | ### Sandboxes | Method | Returns | HTTP | |---|---|---| | `func (c *Client) ListSandboxes(ctx context.Context) ([]Info, error)` | Every sandbox | `GET /kosa8/v1/sandboxes` | | `func (c *Client) GetSandbox(ctx context.Context, idOrName string) (Info, error)` | The first sandbox whose `id`, then `name`, matches; `nil, nil` when none does | `GET /kosa8/v1/sandboxes`, filtered in the client | | `func (c *Client) CreateSandbox(ctx context.Context, o CreateOptions) (Info, error)` | Keys `sandbox` (an object), `boot_ms`, and `broker` when one was asked for | `POST /kosa8/v1/sandboxes`. `KindUsage` before sending if `EgressAllow` is set with an `Egress` other than `""` or `"deny"`, a `Mounts` entry is malformed, or `Capture` has no `Path` | | `func (c *Client) StartSandbox(ctx context.Context, sandbox string, call ...Call) (Info, error)` | Keys `sandbox`, `boot_ms` | `POST …/up` | | `func (c *Client) StopSandbox(ctx context.Context, sandbox string, call ...Call) (Info, error)` | Key `ok`, and others | `POST …/down` | | `func (c *Client) DeleteSandbox(ctx context.Context, sandbox string, o DeleteOptions) (Info, error)` | The daemon's answer (`ok`, …) plus `checkpoints_dropped` (an `int`) | `DELETE …` (`?wipe=1`); then, unless `KeepCheckpoints`, removes the sandbox's records from the store and sends `DELETE /kosa8/v1/snapshots/{id}` for each, ignoring their errors | | `func (c *Client) PullIntoSandbox(ctx context.Context, sandbox, ref string, call ...Call) (Info, error)` | Keys `ok`, `image` | `POST …/pull` `{"ref"}` | | `func (c *Client) SandboxImages(ctx context.Context, sandbox string) ([]string, error)` | Image references | `GET …/images` | | `func (c *Client) SandboxContainers(ctx context.Context, sandbox string) ([]Info, error)` | Containers: `id`, `image`, `status`, … | `GET …/containers` | | `func (c *Client) SandboxEgress(ctx context.Context, sandbox string) (Info, error)` | `policy`, `stats`, and more | `GET …/egress` | ### Running code | Method | Returns | HTTP | |---|---|---| | `func (c *Client) Run(ctx context.Context, sandbox, image string, args []string, opts *RunOptions) (ExecResult, error)` | The collected result. On error, the output so far | `POST …/run` `{"image","args","env"?}`, NDJSON frames. The image must already be in the sandbox | | `func (c *Client) StartContainer(ctx context.Context, sandbox, image string, args []string, call ...Call) (string, error)` | The container id | `POST …/start` `{"image","args"}` | | `func (c *Client) Exec(ctx context.Context, sandbox, container string, args []string, opts *RunOptions) (ExecResult, error)` | The collected result. A non-empty `opts.Env` is a `KindUsage` error before sending: the daemon's exec takes no environment | `POST …/exec` `{"container","args"}`, NDJSON frames | | `func (c *Client) ExecFrames(ctx context.Context, sandbox, container string, args []string, fn func(Frame) error, call ...Call) error` | nil when the stream ends cleanly; `fn`'s error if it returned one; a `KindStream` error when the final frame carries an error (after `fn` has seen that frame) | `POST …/exec`; calls `fn` per frame, including the final `Done` frame | | `func (c *Client) StartInteractive(ctx context.Context, sandbox, image string, args []string, o InteractiveOptions) (string, error)` | The container id | `POST …/start-interactive` `{"image","args","env","tty"}` | | `func (c *Client) WriteStdin(ctx context.Context, sandbox, container string, r io.Reader, call ...Call) (Info, error)` | `{"ok": true}`. A second call for the same sandbox and container on the same `Client` is a `KindUsage` error, sent nothing | `POST …/containers/{cid}/stdin`, body streamed from `r` as `application/octet-stream`; stdin closes when `r` returns EOF, so `r` is the whole of stdin (use an `io.Pipe` to feed it over time) | | `func (c *Client) Attach(ctx context.Context, sandbox, container string, opts *RunOptions) (ExecResult, error)` | The collected result when the container exits | `POST …/containers/{cid}/attach`, NDJSON frames | `Run`, `Exec` and `Attach` fail with `KindStream` when the stream ends with an error frame, with a frame that cannot be decoded, or with no final frame; with `KindTimeout` when `RunOptions.Timeout` or the context's deadline passes. In both cases the error's `Stdout`/`Stderr` and the returned `ExecResult` hold the output so far. A non-zero exit code is not an error. `opts` may be nil. ### Snapshots, fork, explore | Method | Returns | HTTP | |---|---|---| | `func (c *Client) Snapshot(ctx context.Context, sandbox string, o SnapshotOptions) (Info, error)` | Keys `snapshot` (an object), `snapshot_ms`, … | `POST …/snapshot` `{"id"?,"encrypt"?}` | | `func (c *Client) ListSnapshots(ctx context.Context) ([]Info, error)` | Every snapshot | `GET /kosa8/v1/snapshots` | | `func (c *Client) RestoreSnapshot(ctx context.Context, snapshotID string, o RestoreOptions) (Info, error)` | Keys `sandbox`, `restore_ms` | `POST /kosa8/v1/snapshots/{id}/restore` `{"name"?,"label"?,"allow_tampered"?}` | | `func (c *Client) DeleteSnapshot(ctx context.Context, snapshotID string, o SnapshotDeleteOptions) (Info, error)` | The daemon's answer | `DELETE /kosa8/v1/snapshots/{id}` (`?wipe=1`, `&force=1`) | | `func (c *Client) Fork(ctx context.Context, sandbox string, n int, o ForkOptions) (ForkResult, error)` | `ForkResult` | `POST …/fork` `{"n","prefix"?,"label"?}`; `n` below 1 is sent as 1 | | `func (c *Client) Explore(ctx context.Context, sandbox string, variants []string, o ExploreOptions) (ExploreResult, error)` | `ExploreResult` | `POST …/pull` (unless `NoPull`), `POST …/fork` with `n = len(variants)`, one `POST /kosa8/v1/sandboxes/{branch}/run` of `sh -c VARIANT` per branch, concurrently, then `DELETE /kosa8/v1/sandboxes/{branch}` for each branch not kept | `Explore` fails with `KindUsage` for fewer than two variants, when the fork returned fewer branches than variants, and when `Keep` names no branch (then it returns the result with every branch kept). A branch that fails to run is recorded in its `Error` field rather than failing the call; connection-level failures are retried up to four times, 0.5 s, 1 s and 1.5 s apart. ### Checkpoints | Method | Returns | How | |---|---|---| | `func (c *Client) Checkpoint(ctx context.Context, sandbox, label string, call ...Call) (CheckpointResult, error)` | `CheckpointResult` | Picks `-ckpt-`, `POST …/snapshot` with that id (on a 409 `already exists`, from a concurrent checkpoint, it moves to the next number, up to 10 tries; any other 409 is returned), `DELETE /kosa8/v1/snapshots/{id}` for each evicted checkpoint (past `MaxCheckpointsPerSandbox`), then records it in the store | | `func (c *Client) ListCheckpoints(sandbox string) ([]Checkpoint, error)` | Oldest first, by `Created` | The store only; no HTTP | | `func (c *Client) Rewind(ctx context.Context, sandbox, to string, call ...Call) (RewindResult, error)` | `RewindResult` | Resolves `to` (an id, or a label without regard to case, newest first; `""` = the latest), `DELETE …`, `POST /kosa8/v1/snapshots/{id}/restore` `{"name": sandbox}`, removes later checkpoints from the store and sends `DELETE /kosa8/v1/snapshots/{id}` for each | Checkpoint errors: `KindCheckpoint` for an invalid sandbox name (empty, `.`, `..`, containing `/` or `\`, or starting with a dot), no checkpoints to rewind to, or a `to` that matches nothing. If the restore step of `Rewind` fails, the error is `KindCheckpoint` wrapping the daemon's error (`Err`), with the message `restore failed and NAME no longer exists (it was deleted to free its name for the restore): … — the checkpoint is intact; recover with RestoreSnapshot("ID", RestoreOptions{Name: "NAME"})`; by then the sandbox has been deleted and the snapshot is intact. If the delete step fails, its error is returned unchanged and nothing has changed. ### Handle ``` func (c *Client) Sandbox(id string) *Sandbox ``` Returns a handle bound to one sandbox. It sends nothing and does not check that the sandbox exists. ## `Sandbox` ``` type Sandbox struct { ID string // contains filtered or unexported fields } ``` | Method | Calls | |---|---| | `func (s *Sandbox) Info(ctx context.Context) (Info, error)` | `GetSandbox` | | `func (s *Sandbox) Start(ctx context.Context, call ...Call) (Info, error)` | `StartSandbox` | | `func (s *Sandbox) Stop(ctx context.Context, call ...Call) (Info, error)` | `StopSandbox` | | `func (s *Sandbox) Delete(ctx context.Context, o DeleteOptions) (Info, error)` | `DeleteSandbox` | | `func (s *Sandbox) Pull(ctx context.Context, ref string, call ...Call) (Info, error)` | `PullIntoSandbox` | | `func (s *Sandbox) Run(ctx context.Context, image string, args []string, o *RunOptions) (ExecResult, error)` | `Run` | | `func (s *Sandbox) StartContainer(ctx context.Context, image string, args []string, call ...Call) (string, error)` | `StartContainer` | | `func (s *Sandbox) Exec(ctx context.Context, container string, args []string, o *RunOptions) (ExecResult, error)` | `Exec` | | `func (s *Sandbox) Containers(ctx context.Context) ([]Info, error)` | `SandboxContainers` | | `func (s *Sandbox) Images(ctx context.Context) ([]string, error)` | `SandboxImages` | | `func (s *Sandbox) Egress(ctx context.Context) (Info, error)` | `SandboxEgress` | | `func (s *Sandbox) Snapshot(ctx context.Context, o SnapshotOptions) (Info, error)` | `Snapshot` | | `func (s *Sandbox) Fork(ctx context.Context, n int, o ForkOptions) (ForkResult, error)` | `Fork` | | `func (s *Sandbox) Explore(ctx context.Context, variants []string, o ExploreOptions) (ExploreResult, error)` | `Explore` | | `func (s *Sandbox) Checkpoint(ctx context.Context, label string, call ...Call) (CheckpointResult, error)` | `Checkpoint` | | `func (s *Sandbox) Checkpoints() ([]Checkpoint, error)` | `ListCheckpoints` | | `func (s *Sandbox) Rewind(ctx context.Context, to string, call ...Call) (RewindResult, error)` | `Rewind` | There is no `ExecFrames`, `StartInteractive`, `WriteStdin`, `Attach`, `RestoreSnapshot` or `DeleteSnapshot` on the handle; call them on the client with `s.ID`. ## Option types ### `Call` ``` type Call struct{ Approval string } ``` Per-request options: a signed approval id, sent as `X-Kosa8-Approval` (trimmed; omitted when empty). ### `CreateOptions` ``` type CreateOptions struct { Name string CPUs int MemMB int DiskGB int Mounts []string // host:guest[:ro]; host must be absolute Egress string // deny | any | fakenet | allow:host,host // EgressAllow limits the network to these hosts (sets Egress to allow:…). EgressAllow []string // Capture records the sandbox's network traffic as pcap segments under // Capture.Path, a file on the host (pcapng). nil records nothing. Capture *CaptureOptions Labels map[string]string Broker any // the credential-broker config, passed through Approval string } // CaptureOptions turn on packet capture for a sandbox (daemon 0.9.36 writes one pcapng file at Path). type CaptureOptions struct { Path string // host file the capture is written to (required) MaxBytes int64 // per segment; 0 is the daemon's default MaxFiles int // segments kept; 0 is the daemon's default } ``` | Field | Sent as | Notes | |---|---|---| | `Name` | `name` | Omitted when empty: the daemon picks one. | | `CPUs`, `MemMB`, `DiskGB` | `cpus`, `mem_mb`, `disk_gb` | Omitted when 0 or less: the daemon's defaults. | | `Mounts` | `mounts`, each `"host:guest[:ro\|:rw]"` as `{"host_path","guest_path","read_only"}` | Anything that is not two non-empty parts (plus an optional `ro`/`rw`) is a `KindUsage` error, and so (from v0.1.2) is a host path that is not absolute (`filepath.IsAbs`) or a guest path not starting with `/`. Shared with virtiofs. v0.1.0 sent strings and every mount failed. See [the guide](https://docs.kosa8.com/guides/go-guide/#mounts). | | `Egress` | `egress` | `deny`, `any`, `fakenet` or `allow:h1,h2`. Empty: the daemon's default (the org policy's, if it sets one). | | `EgressAllow` | `egress: "allow:h1,h2"` | Each host trimmed. Combined with an `Egress` other than `""`/`"deny"` it is a `KindUsage` error. | | `Capture` | `capture: {"path", "max_bytes"?, "max_files"?}` | nil: no capture. Empty `Path` is a `KindUsage` error. `Path` is a pcapng **file** path (like `kosa8 sandbox create --pcap FILE`); a directory is refused with a 500 `capture: open …: is a directory`. `MaxBytes`, `MaxFiles` are sent only when positive. v0.1.0 had a `bool` here that never worked. | | `Labels` | `labels` | Omitted when empty. | | `Broker` | `broker` | Marshalled with `encoding/json` as given. | | `Approval` | header | `X-Kosa8-Approval`. | ### Other option types ``` type DeleteOptions struct { Wipe bool // overwrite the disks, not just unlink them KeepCheckpoints bool // keep this sandbox's checkpoint snapshots Approval string } type RunOptions struct { Env []string // KEY=VALUE OnStdout func([]byte) OnStderr func([]byte) OnStatus func([]byte) // pull/progress lines Timeout time.Duration Approval string } type InteractiveOptions struct { Env []string TTY bool Approval string } type SnapshotOptions struct { ID string // default: the daemon picks one Encrypt bool Approval string } type RestoreOptions struct { Name string Label string AllowTampered bool Approval string } type SnapshotDeleteOptions struct { Wipe, Force bool Approval string } type ForkOptions struct { Prefix string Label string Approval string } type ExploreOptions struct { Image string // default alpine:latest KeepAll bool // keep every branch Keep string // keep this branch instead of the fastest success Timeout time.Duration // per branch; default 120s Prefix string NoPull bool // the image is already in the sandbox Approval string } ``` | Field | Behaviour | |---|---| | `DeleteOptions.Wipe` | `?wipe=1` on the sandbox delete and on each checkpoint snapshot delete. | | `DeleteOptions.KeepCheckpoints` | Leaves the store and the snapshots alone; `checkpoints_dropped` is 0. | | `RunOptions.Env` | Sent by `Run` (when non-nil). `Exec` refuses a non-empty one with `KindUsage`; `Attach` ignores it. | | `RunOptions.OnStdout`, `OnStderr` | Called with each chunk as it arrives, on the calling goroutine. The chunk is also collected in the result. | | `RunOptions.OnStatus` | Called with `status` frames (progress lines); these are not collected. | | `RunOptions.Timeout` | When positive, wraps the context with `context.WithTimeout` for this call. | | `InteractiveOptions.Env`, `TTY` | Sent as `env` (null when nil) and `tty`. | | `SnapshotOptions.ID`, `Encrypt` | Sent as `id` and `encrypt: true` when set. | | `RestoreOptions.Name`, `Label`, `AllowTampered` | Sent as `name`, `label`, `allow_tampered: true` when set. `Name` empty: the daemon picks one. | | `SnapshotDeleteOptions.Wipe`, `Force` | `?wipe=1`, `?force=1`. | | `ForkOptions.Prefix`, `Label` | Sent when set. The daemon names forks `1`, `2`, …; its default prefix is `-fork`. | | `ExploreOptions.Prefix` | Default `-explore-`. | ## Result types ``` type Info = map[string]any type ExecResult struct { ExitCode int StdoutBytes []byte StderrBytes []byte } func (r ExecResult) Stdout() string func (r ExecResult) Stderr() string func (r ExecResult) OK() bool // ExitCode == 0 type ForkResult struct { Forks []Info `json:"forks"` Snapshot any `json:"snapshot"` ForkMS int64 `json:"fork_ms"` Count int `json:"count"` } type ExploreBranch struct { Name string Variant string Exit int Output string Error string // set when the branch could not run DurationMS int64 } type ExploreResult struct { Parent string Branches []ExploreBranch Winner string // "" when no branch succeeded Removed int } type Checkpoint struct { ID string `json:"id"` Sandbox string `json:"sandbox"` Label string `json:"label"` Auto bool `json:"auto,omitempty"` Created time.Time `json:"created"` } type CheckpointResult struct { Checkpoint Checkpoint Evicted []string Snapshot Info } type RewindResult struct { Sandbox any Checkpoint Checkpoint Dropped []string RestoreMS any } ``` | Field | Meaning | |---|---| | `ExploreBranch.Output` | stdout followed by stderr, trailing newlines trimmed. | | `ExploreBranch.DurationMS` | Wall time for the branch, retries included. | | `ExploreResult.Winner` | The successful branch (no `Error`, exit 0) with the smallest `DurationMS`, or `Keep` when set. | | `ExploreResult.Removed` | Branches deleted successfully. With `KeepAll`, 0. With no winner and no `KeepAll`, every branch is deleted. | | `CheckpointResult.Evicted` | Ids of checkpoints removed to stay within 10. | | `CheckpointResult.Snapshot` | The daemon's snapshot answer (`snapshot`, `snapshot_ms`, …). | | `RewindResult.Sandbox`, `RestoreMS` | The restore answer's `sandbox` (a `map[string]any`) and `restore_ms` (a `float64`), untyped. | | `RewindResult.Dropped` | Ids of checkpoints taken after the target, now removed. | ## Checkpoint stores ``` const MaxCheckpointsPerSandbox = 10 type CheckpointStore interface { Load() (map[string][]Checkpoint, error) // Update runs fn on the stacks and saves what it leaves, serialised against other updates. Update(fn func(map[string][]Checkpoint) error) error } type FileCheckpointStore struct { Path string // contains filtered or unexported fields } func NewFileCheckpointStore(path string) *FileCheckpointStore func (f *FileCheckpointStore) Load() (map[string][]Checkpoint, error) func (f *FileCheckpointStore) Update(fn func(map[string][]Checkpoint) error) error type MemoryCheckpointStore struct { // contains filtered or unexported fields } func (m *MemoryCheckpointStore) Load() (map[string][]Checkpoint, error) func (m *MemoryCheckpointStore) Update(fn func(map[string][]Checkpoint) error) error ``` | Name | Behaviour | |---|---| | `NewFileCheckpointStore` | `""` means `~/.kosa8/checkpoints.json`, the CLI's file. | | `FileCheckpointStore.Load` | A missing file is an empty store; so is a file that is not valid JSON (a corrupt store must not make the sandbox unusable). | | `FileCheckpointStore.Update` | Creates the directory (0700), takes an exclusive `flock` on `.lock` (the CLI's lock; no lock on systems without `flock`, only an in-process mutex), loads, runs `fn`, writes `.tmp` (0600) and renames it over the file. If `fn` fails nothing is written. | | `MemoryCheckpointStore` | The zero value is ready to use; use it by pointer. `Load` returns a copy; `Update` works on a copy and keeps it only if `fn` succeeds. | ## Endpoint, frames and constants ``` const Version = "0.1.2" const ( ApprovalHeader = "X-Kosa8-Approval" ActorHeader = "X-Kosa8-Actor" AgentTokenHeader = "X-Kosa8-Agent-Token" ) func DefaultSocketPath() string // $HOME/.kosa8/run/kosa8.sock type Endpoint struct { Unix string // socket path, when local URL *url.URL // remote worker, when not Token string } func (e Endpoint) Target() string // "unix://PATH" or "SCHEME://HOST" func ResolveEndpoint(host, socketPath, token string, insecure bool, getenv func(string) string) (Endpoint, error) type Frame struct { Stream string `json:"stream,omitempty"` // stdout | stderr | status Data []byte `json:"data,omitempty"` Done bool `json:"done,omitempty"` OK bool `json:"ok,omitempty"` Error string `json:"error,omitempty"` Exit *int `json:"exit,omitempty"` ID string `json:"id,omitempty"` } ``` `ResolveEndpoint` follows the CLI's `KOSA8_HOST` rules. It sends nothing. - The address is `host` if non-empty, else `KOSA8_HOST` from `getenv` (nil means `os.Getenv`), trimmed of spaces and trailing slashes. - Empty: the unix socket `socketPath`, or `DefaultSocketPath()`. - `unix:///path`: that socket. `tcp://h:p` and a bare `h:p`: `http://h:p`. `http://` and `https://`: as given (the scheme's case is ignored, and a path is kept as a prefix). Any other scheme, or no host name, is a `KindUsage` error. - Plain `http` to a host other than `localhost`, `::1` or `127.*` is a `KindUsage` error unless `insecure` or `KOSA8_INSECURE=1`. - `token` is kept for a URL endpoint; when empty and the address came from `KOSA8_HOST`, `KOSA8_TOKEN` is used. A unix endpoint carries no token. `Frame` is one NDJSON frame of a run, exec or attach stream, with `Data` decoded from base64. Output frames have `Stream` and `Data`; the last frame has `Done`, and either `OK` and `Exit`, or `Error`. ## Errors ``` type Error struct { Kind Kind Message string Status int // HTTP status, when the daemon answered DaemonMessage string // the daemon's "error" text, verbatim Remediation string // the daemon's "remediation": what to do about it Method, Path string Body any // the decoded response body Approval map[string]any // set when Kind == KindApprovalRequired: the daemon's refusal Stdout string // KindStream: output produced before the error Stderr string Err error // the underlying cause, if any } func (e *Error) Error() string // Message func (e *Error) Unwrap() error // Err func (e *Error) Is(target error) bool func AsError(err error) (*Error, bool) type Kind string const ( KindConnection Kind = "connection" // daemon unreachable KindAPI Kind = "api" // any other HTTP error KindBadRequest Kind = "bad_request" // 400 KindAuth Kind = "auth" // 401 KindPolicy Kind = "policy" // 403 KindApprovalRequired Kind = "approval_required" // 403 needing a signed approval KindNotFound Kind = "not_found" // 404 KindConflict Kind = "conflict" // 409 KindUnsupported Kind = "unsupported" // 501 KindStream Kind = "stream" // a stream ended with an error frame KindCheckpoint Kind = "checkpoint" // client-side checkpoint bookkeeping KindTimeout Kind = "timeout" KindUsage Kind = "usage" // bad arguments ) var ( ErrConnection = &Error{Kind: KindConnection} ErrAPI = &Error{Kind: KindAPI} ErrBadRequest = &Error{Kind: KindBadRequest} ErrAuth = &Error{Kind: KindAuth} ErrPolicy = &Error{Kind: KindPolicy} ErrApprovalRequired = &Error{Kind: KindApprovalRequired} ErrNotFound = &Error{Kind: KindNotFound} ErrConflict = &Error{Kind: KindConflict} ErrUnsupported = &Error{Kind: KindUnsupported} ErrStream = &Error{Kind: KindStream} ErrCheckpoint = &Error{Kind: KindCheckpoint} ErrTimeout = &Error{Kind: KindTimeout} ErrUsage = &Error{Kind: KindUsage} ) ``` `Is` matches a target `*Error` with an empty `Message` (a sentinel, or `&kosa8.Error{Kind: k}`) by `Kind`. Two extra rules: `ErrPolicy` also matches `KindApprovalRequired`, and `ErrAPI` matches every HTTP kind (`api`, `bad_request`, `auth`, `policy`, `approval_required`, `not_found`, `conflict`, `unsupported`). `ErrUsage` (new in v0.1.1) matches `KindUsage`. `AsError` is `errors.As` into a `*Error`. When each kind occurs, with real messages: [Go errors and troubleshooting](https://docs.kosa8.com/guides/go-errors/). --- Source: https://docs.kosa8.com/guides/go-api/ # Ruby quickstart One `hello.rb`, five minutes. You will connect to kosa8 from Ruby, start a sealed sandbox with no network, run a command in it, save it, break it, put it back, and delete it. **How this page was tested** The script on this page was run against a real kosa8 daemon 0.9.36 on an Apple silicon Mac (macOS 26.0.1), with the `kosa8` gem 0.1.2 and the system Ruby, 2.6.10. The install steps were run from RubyGems into fresh scratch folders, both with `gem install` and through Bundler, and their output is shown as it printed; the installed files were identical to the `v0.1.2` source the script ran against (`ruby -I lib`). The output of the script is what that run printed. Timings are from that run, on a machine busy with other sandboxes at the time, and will differ on yours. ## What this is, in plain words A **sandbox** is a small, separate computer that runs inside your computer. It has its own operating system, its own files and its own network settings. Code running inside it cannot see your files, your passwords or your network unless you allow it. If the code does something bad, the damage stays inside the sandbox, and you can throw the sandbox away. That matters when code comes from somewhere you do not fully trust: an AI agent writing and running its own commands, a user's script on your server, a package you have not reviewed. kosa8 runs that code in a sandbox on your own machine, not in someone else's cloud. The **Ruby gem** (`kosa8`) lets a Ruby program do all of this: create sandboxes, run commands in them, read the output, save a sandbox's exact state and go back to it later. It talks to the kosa8 background service (the **daemon**) that runs on your machine. It uses only Ruby's standard library, so it adds no other gems to your project. ## What you need | Part | Requirement | |---|---| | kosa8 | Installed, with its daemon running. See [Install](https://docs.kosa8.com/guides/install/). `kosa8 version` shows both parts. | | Machine | macOS on Apple silicon, x86-64 Linux with KVM, or Windows 11 on x86-64 (run your program inside WSL2). Sandboxes are real virtual machines. | | Ruby | 2.6 or later (the gemspec's `required_ruby_version`). The Ruby that ships with macOS (2.6) works. | | Disk | Room for the guest image the daemon downloads on first use (about 1.5 GB), plus sandboxes and checkpoints. | ``` $ kosa8 version kosa8 client 0.9.36 (f6124c0, darwin/arm64) kosa8 daemon 0.9.36 (up 4173s) $ ruby -v ruby 2.6.10p210 (2022-04-12 revision 67958) [universal.arm64e-darwin25] ``` If the second line says the daemon is not running, start it with `kosa8 daemon start` (the Mac app starts it for you). ## Install the gem This page uses version **0.1.2**. Install it directly: ``` $ gem install kosa8 -v 0.1.2 --no-document Successfully installed kosa8-0.1.2 1 gem installed ``` Or, in a project, add it to the `Gemfile` and let Bundler install it: ``` source "https://rubygems.org" gem "kosa8", "~> 0.1.2" ``` ``` $ bundle install --path vendor/bundle Fetching gem metadata from https://rubygems.org/. Resolving dependencies... Using bundler 1.17.2 Fetching kosa8 0.1.2 Installing kosa8 0.1.2 Bundle complete! 1 Gemfile dependency, 2 gems now installed. Bundled gems are installed into `./vendor/bundle` $ bundle exec ruby -e 'require "kosa8"; puts Kosa8::VERSION' 0.1.2 ``` Use 0.1.2 or later. In 0.1.0 a command that prints nothing for 60 seconds fails with a timeout; 0.1.1 and 0.1.2 fix that and more (see the [developer guide](https://docs.kosa8.com/guides/rb-guide/)). On macOS's own Ruby, a plain `gem install` wants to write to a system folder and fails without `sudo`. Do not use `sudo`: install into your home with `gem install --user-install kosa8`, use a Ruby version manager, or use Bundler with a project folder as above. (The tests for this page used a scratch folder.) The gem's name, its `require` and its module are all `kosa8` / `Kosa8`. ## The walkthrough The steps below are parts of one file, `hello.rb`. The whole file, and what it printed, is at the end. Everything after the sandbox is created sits in a `begin … ensure` block, so the clean-up in step 6 always runs, even when a step fails. ### Connect ``` # 1. Connect. With no options the client uses ~/.kosa8/run/kosa8.sock. k = Kosa8::Client.new puts "kosa8 #{k.version} at #{k.transport.endpoint.target}" ``` ``` kosa8 0.9.36 at unix:///Users/dhruvit/.kosa8/run/kosa8.sock ``` `Kosa8::Client.new` only works out where to connect; nothing is sent until the first call. `version` asks the daemon for its version, which proves the connection works. If the daemon is not running, this is where you find out: it raises `Kosa8::ConnectionError`, and the message says how to start it. ### Create a sandbox with no network ``` # 2. Create a sandbox: a microVM with its own kernel and no network at all. created = k.create_sandbox(name: "doc-rb-demo", egress: "deny") sb = k.sandbox("doc-rb-demo") begin info = created["sandbox"] puts "sandbox #{info['name']}: #{info['state']}, egress #{info['egress']}, booted in #{created['boot_ms']}ms" ``` ``` sandbox doc-rb-demo: running, egress deny, booted in 10564ms ``` `egress: "deny"` closes the network completely. ("Egress" means traffic going out.) The rule is enforced on your computer, outside the sandbox, so code inside cannot turn it off. `k.sandbox(name)` returns a handle with the sandbox's name filled in, so later calls are shorter. The answer is the daemon's JSON as a Ruby hash with string keys. Booting took 11 seconds here because the machine was busy; on an idle Mac it is usually a second or two. ### Pull an image and run a command ``` # 3. Pull an image into the sandbox (fetched on the host), then run a command to completion. sb.pull("alpine:latest") res = sb.run("alpine:latest", ["sh", "-c", "echo kernel $(uname -r); wget -q -T 3 -O- http://example.com || echo no network; exit 3"]) puts "exit #{res.exit_code} (ok? #{res.ok?})" print res.stdout ``` ``` exit 3 (ok? false) kernel 6.18.35 no network ``` Code in a sandbox runs in containers on the sandbox's own engine, so it needs an **image** (a packaged set of files and programs; `alpine` is a tiny Linux). `pull` fetches it on the host and copies it in, which works even though the sandbox has no network. `run` starts a fresh container, waits for it to finish and returns a `Kosa8::ExecResult`. The kernel is the sandbox's own, and the download failed, as it should. A command that exits with a non-zero code is a *result*, not an exception: `exit_code` is 3 and `ok?` is false. An exception is raised only when the command could not run at all. ### Keep a container running ``` # 4. Keep a container running, and write a file in it. cid = sb.start_container("alpine:latest", ["sh", "-c", "while :; do sleep 3600; done"]) sh = lambda do |cmd| r = sb.exec(cid, ["sh", "-c", cmd]) (r.stdout + r.stderr).strip end puts "wrote /note: #{sh.call('echo v1 > /note; cat /note')}" ``` ``` wrote /note: v1 ``` `run` starts from a clean image every time. To keep files between commands, start a container that stays up and `exec` commands in it. `start_container` returns the container's id. ### Checkpoint, break something, rewind ``` # 5. Checkpoint, break something, rewind. cp = sb.checkpoint(label: "before the risky bit") puts "checkpoint #{cp['checkpoint']['id']} (#{cp['checkpoint']['label']}) in #{cp['snapshot']['snapshot_ms']}ms" sh.call("rm /note") puts "after rm: #{sh.call('cat /note')}" rw = sb.rewind puts "rewound to #{rw['checkpoint']['id']} in #{rw['restore_ms']}ms" puts "after rewind: /note = #{sh.call('cat /note')}" ``` ``` checkpoint doc-rb-demo-ckpt-1 (before the risky bit) in 520ms after rm: cat: can't open '/note': No such file or directory rewound to doc-rb-demo-ckpt-1 in 556ms after rewind: /note = v1 ``` A **checkpoint** saves the whole running sandbox: memory, running programs and disks. `rewind` with no arguments goes back to the latest one. The sandbox returns under the same name with the same container still running, so the same container id works and the deleted file is back. Checkpoints are kept in the same list the kosa8 command line uses, so `kosa8 rewind doc-rb-demo` would have done the same. ### Delete ``` ensure # 6. Always clean up, even if a step above failed. puts "deleted: #{sb.delete}" end ``` ``` deleted: {"ok"=>true, "checkpoints_dropped"=>1} ``` `delete` removes the sandbox and, unless you pass `keep_checkpoints: true`, its checkpoints too: here, the one taken in step 5. ## The whole file ``` require "kosa8" # 1. Connect. With no options the client uses ~/.kosa8/run/kosa8.sock. k = Kosa8::Client.new puts "kosa8 #{k.version} at #{k.transport.endpoint.target}" # 2. Create a sandbox: a microVM with its own kernel and no network at all. created = k.create_sandbox(name: "doc-rb-demo", egress: "deny") sb = k.sandbox("doc-rb-demo") begin info = created["sandbox"] puts "sandbox #{info['name']}: #{info['state']}, egress #{info['egress']}, booted in #{created['boot_ms']}ms" # 3. Pull an image into the sandbox (fetched on the host), then run a command to completion. sb.pull("alpine:latest") res = sb.run("alpine:latest", ["sh", "-c", "echo kernel $(uname -r); wget -q -T 3 -O- http://example.com || echo no network; exit 3"]) puts "exit #{res.exit_code} (ok? #{res.ok?})" print res.stdout # 4. Keep a container running, and write a file in it. cid = sb.start_container("alpine:latest", ["sh", "-c", "while :; do sleep 3600; done"]) sh = lambda do |cmd| r = sb.exec(cid, ["sh", "-c", cmd]) (r.stdout + r.stderr).strip end puts "wrote /note: #{sh.call('echo v1 > /note; cat /note')}" # 5. Checkpoint, break something, rewind. cp = sb.checkpoint(label: "before the risky bit") puts "checkpoint #{cp['checkpoint']['id']} (#{cp['checkpoint']['label']}) in #{cp['snapshot']['snapshot_ms']}ms" sh.call("rm /note") puts "after rm: #{sh.call('cat /note')}" rw = sb.rewind puts "rewound to #{rw['checkpoint']['id']} in #{rw['restore_ms']}ms" puts "after rewind: /note = #{sh.call('cat /note')}" ensure # 6. Always clean up, even if a step above failed. puts "deleted: #{sb.delete}" end ``` ``` $ ruby hello.rb kosa8 0.9.36 at unix:///Users/dhruvit/.kosa8/run/kosa8.sock sandbox doc-rb-demo: running, egress deny, booted in 10564ms exit 3 (ok? false) kernel 6.18.35 no network wrote /note: v1 checkpoint doc-rb-demo-ckpt-1 (before the risky bit) in 520ms after rm: cat: can't open '/note': No such file or directory rewound to doc-rb-demo-ckpt-1 in 556ms after rewind: /note = v1 deleted: {"ok"=>true, "checkpoints_dropped"=>1} ``` With Bundler, run it as `bundle exec ruby hello.rb`. (The test ran it as `ruby -I ~/kosa8-rb/lib hello.rb`, against the v0.1.2 source, which is the same code as the published gem.) ## Look at it from the command line The gem and the CLI drive the same daemon and share the same checkpoint list, so you can watch what your program does, or finish a job by hand: ``` $ kosa8 sandbox ls $ kosa8 checkpoint ls doc-rb-demo $ kosa8 audit ls -n 10 # your program's calls, recorded as actor "tool" ``` **If the rewind fails on a Mac** Restoring a saved machine needs the screen to be unlocked. The checkpoint succeeds and the rewind fails with `macOS blocks restoring a VM while the screen is locked; unlock and retry`. By then the rewind has already removed the sandbox; the checkpoint's snapshot is intact, and the `Kosa8::CheckpointError` message ends with the `restore_snapshot(…)` call that brings it back. See [Ruby errors and troubleshooting](https://docs.kosa8.com/guides/rb-errors/). **Next** [Ruby developer guide](https://docs.kosa8.com/guides/rb-guide/): every capability in detail, with recipes. [Errors and troubleshooting](https://docs.kosa8.com/guides/rb-errors/) and the [API reference](https://docs.kosa8.com/guides/rb-api/) list everything else. --- Source: https://docs.kosa8.com/guides/rb-quickstart/ # Ruby developer guide Everything the `kosa8` gem can do, one capability at a time. Each section starts with what the thing is, in plain words, then goes as deep as the code goes: what is sent, what comes back, and the edges you can trip on. Every output on this page is from a real run. **How this page was tested** Each example was run against a real kosa8 daemon 0.9.36 on an Apple silicon Mac (macOS 26.0.1), with the system Ruby 2.6.10 and the `kosa8` gem 0.1.2 (tag `v0.1.2`, loaded with `ruby -I lib`; the same files as `gem install kosa8 -v 0.1.2` from RubyGems). The examples were first run with 0.1.0; every section whose behaviour changed in 0.1.1 or 0.1.2 (stdin, timeouts, callbacks, checkpoints, the agent loop) was run again with 0.1.1 or 0.1.2, and the other outputs are from the 0.1.0 runs of code that did not change. The output is pasted from those runs; long hashes are cut where marked. The machine was running other people's sandboxes at the same time, so boot and fork times are slower than on an idle Mac. Every sandbox and snapshot the examples created was deleted afterwards. Not tested here: approvals (they need an org policy that asks for them), `mounts:`, `capture:`, `broker:`, encrypted snapshots, and remote workers over `https` (the endpoint rules were checked without a worker). ## What this is, in plain words kosa8 gives you **sandboxes**: small virtual computers that run on your own machine, each with its own Linux kernel, disk and network rules. Code inside one cannot reach your files or your network unless you let it. This gem is how a Ruby program drives them. Six words come up on every page: | Word | Plain meaning | |---|---| | **daemon** | The kosa8 background service on your machine. It does the work; the gem sends it requests. | | **sandbox** | One virtual computer (a microVM). It has a name, such as `doc-rb-demo`. | | **image** and **container** | Programs run inside a sandbox as containers, started from an image (a packaged set of files, such as `alpine:latest`, a tiny Linux). | | **egress** | Network traffic going out of the sandbox. You decide whether any is allowed, and to where. | | **snapshot** and **checkpoint** | A saved copy of a running sandbox: memory, running programs and disk. A checkpoint is a snapshot kept on a per-sandbox "undo" list. | | **fork** | Making several live copies of a running sandbox, so you can try different things in each. | For developers: the gem is a thin, dependency-free client for the daemon's native HTTP API (`/kosa8/v1`), spoken over a unix socket with `Net::HTTP`. Answers are the daemon's JSON, parsed into hashes with string keys. Process output streams back as NDJSON frames. Checkpoints are bookkeeping on the client side, in the same file the CLI uses. Method for method it matches the [other kosa8 SDKs](https://docs.kosa8.com/guides/sdks/). ## The two objects you use ``` require "kosa8" k = Kosa8::Client.new # one per program is enough; safe to share across threads sb = k.sandbox("doc-rb-demo") # a handle: the same methods, with the sandbox name filled in k.run("doc-rb-demo", "alpine:latest", ["echo", "hi"]) # these two lines sb.run("alpine:latest", ["echo", "hi"]) # do the same thing ``` `Kosa8::Client` has every method. `Kosa8::Sandbox` is a convenience: it holds a client and a name and forwards to the client. Making a handle sends nothing and does not check the sandbox exists. Three snapshot methods are only on the client (`restore_snapshot`, `delete_snapshot`, `list_snapshots`); reach them with `sb.client`. (Since 0.1.1 the handle also has `exec_frames`, `start_interactive`, `write_stdin` and `attach`.) The full list is in the [API reference](https://docs.kosa8.com/guides/rb-api/). ## Connecting **In plain words:** by default the gem talks to the kosa8 daemon on the same machine, through a file called a unix socket. Only programs running as you on that computer can use it, so no password is needed. To drive kosa8 on another machine (a "worker"), you give an address and a token. ### Where requests go With no options, requests go to `~/.kosa8/run/kosa8.sock`. Otherwise the gem follows the CLI's rules, so the same environment variables work for both: | Variable | Keyword | Effect | |---|---|---| | `KOSA8_HOST` | `host:` | Where the daemon is. `https://host[:port][/path]`, `http://…`, `tcp://host:port` (read as `http://`), a bare `host:port` (also `http://`), or `unix:///path/to.sock`. Unset or empty: the local socket. | | `KOSA8_TOKEN` | `token:` | The worker's bearer token, sent as `Authorization: Bearer …`. Read from the environment *only* when the address also came from `KOSA8_HOST`: pass `host:` in code and you must pass `token:` too. Never sent over a unix socket. | | `KOSA8_INSECURE` | `insecure:` | `1` allows plain `http://` to a host that is not this machine. Without it the gem refuses, because the token would cross the network in clear text. | | `KOSA8_AGENT_TOKEN` | `agent_token:` | An agent identity (minted with `kosa8 agent token create NAME`), sent as `X-Kosa8-Agent-Token`. See [Agent identity](https://docs.kosa8.com/guides/agent-identity/). | | (none) | `socket_path:` | A different socket file, when no host is set. | What the rules gave, from `Kosa8.resolve_endpoint` (home folder shortened): ``` host=nil env={} -> unix:///Users/you/.kosa8/run/kosa8.sock base_path=nil token=nil host=nil env={"KOSA8_HOST"=>"https://w.example.com/", "KOSA8_TOKEN"=>"t0k"} -> https://w.example.com:443 base_path="" token="t0k" host="tcp://127.0.0.1:7070" env={} -> http://127.0.0.1:7070 base_path="" token=nil host="localhost:7070" env={} -> http://localhost:7070 base_path="" token=nil host="unix:///tmp/other.sock" env={} -> unix:///tmp/other.sock base_path=nil token=nil host="https://w.example.com/kosa8-proxy" env={"KOSA8_TOKEN"=>"t0k"} -> https://w.example.com:443 base_path="/kosa8-proxy" token=nil host=nil env={"KOSA8_HOST"=>"http://10.0.0.5:7440", "KOSA8_INSECURE"=>"1"} -> http://10.0.0.5:7440 base_path="" token=nil ``` Note the sixth line: an explicit `host:` does not pick up `KOSA8_TOKEN`. A path after the host (`/kosa8-proxy`) is kept and put in front of every request, for a worker behind a proxy. And the refusals, raised by `Kosa8::Client.new` before anything is sent: ``` refusing to talk to 10.0.0.5:7440 over plain http: the token and every request would cross the network unencrypted. Use https://10.0.0.5:7440 (a worker behind a TLS-terminating tunnel or proxy), or pass insecure: true / KOSA8_INSECURE=1 if this link is already private and trusted KOSA8_HOST scheme "ftp" is not supported; use http://, https://, tcp:// or unix:// ``` Pass `env: {}` to make a client ignore the environment entirely (useful in tests), or any hash to supply your own values. ### Who is calling: the actor Every request carries `X-Kosa8-Actor`, recorded in the audit log (`kosa8 audit ls`). It is `"tool"` unless you pass `actor: "agent"` or `actor: "human"`; anything else raises `Kosa8::Error`. An org policy can treat the three differently. `headers:` adds your own headers to every request. ## Sandboxes **In plain words:** create a sandbox, use it, delete it. You can also stop one (its disk is kept, its programs end) and start it again later. ``` c = k.create_sandbox(name: "doc-rb-guide", cpus: 2, mem_mb: 1024, egress_allow: ["example.com"], labels: { "team" => "docs" }) puts "create keys: #{c.keys.inspect}" puts "sandbox: #{c['sandbox'].inspect}" ``` ``` create keys: ["boot_ms", "sandbox"] sandbox: {"id"=>"doc-rb-guide", "name"=>"doc-rb-guide", "created"=>"2026-11-03T01:39:22.091122-05:00", "state"=>"running", "cpus"=>2, "mem_mb"=>1024, "disk_gb"=>32, "egress"=>"allow:example.com", "ip"=>"192.168.127.2", "labels"=>{"team"=>"docs"}} ``` Every keyword is optional; leave one out and the daemon's default applies (here, a 32 GB disk). The `name` is also the sandbox's id; it must be unused, or the daemon answers `ConflictError`. `labels` are classification tags that an org policy can check. The lifecycle calls: | Call | What happens | |---|---| | `sb.stop` | Shuts the machine down; disk kept. Answered `{"ok"=>true, "sandbox"=>{… "state"=>"stopped" …}, "warning"=>""}`. | | `sb.start` | Boots it from its own disk (741 ms in our test). Files written to the sandbox's disk survive; running containers do not: `sb.containers` was `[]` afterwards. | | `sb.delete` | Removes it and, by default, its checkpoints. `wipe: true` overwrites its data first; `keep_checkpoints: true` keeps the checkpoints (and their snapshots). | | `sb.info` / `k.get_sandbox(name)` | The sandbox's record, or `nil` when there is none (no exception). | | `k.list_sandboxes` | Every sandbox's record. | ### Network rules (egress) **In plain words:** you decide whether code in the sandbox can reach the internet. The rule is enforced by your computer, outside the sandbox, so nothing inside can switch it off. | You pass | Sent as | Meaning | |---|---|---| | `egress: "deny"` | `deny` | No network at all. | | `egress_allow: ["pypi.org", "github.com"]` | `allow:pypi.org,github.com` | Only these hosts; the safe way to let an agent install packages. | | `egress: { allow: [...] }` | `allow:…` | The same, as a hash. | | `egress: "any"` | `any` | Anything. An org policy may forbid or require approval for it. | | `egress: "fakenet"` | `fakenet` | A simulated internet, for watching what code tries to reach. See [Network rules](https://docs.kosa8.com/guides/network/). | With `egress_allow: ["example.com"]`, from inside the sandbox: ``` r = sb.run("alpine:latest", ["sh", "-c", "wget -q -T 5 -O- http://example.com | head -c 60; echo; " \ "wget -q -T 5 -O- http://github.com >/dev/null 2>&1 && echo github ok || echo github blocked"]) puts r.stdout puts JSON.generate(sb.egress) ``` ``` "docker.io/library/alpine:latest", "ok"=>true} images: ["docker.io/library/alpine:latest"] ``` (The client's own `k.pull(ref)` is different: it pulls into the host's shared engine, not into a sandbox.) ### `run`: output as it happens ``` t = Time.now r = sb.run("alpine:latest", ["sh", "-c", "for i in 1 2 3; do echo out $i; echo err $i >&2; sleep 0.3; done; echo $GREETING"], env: { "GREETING" => "hi from env" }, on_stdout: ->(b) { puts " [live stdout #{(Time.now - t).round(1)}s] #{b.inspect}" }, on_stderr: ->(b) { puts " [live stderr] #{b.inspect}" }, on_status: ->(b) { puts " [status] #{b.inspect}" }) p r ``` ``` [live stdout 16.8s] "out 1\n" [live stderr] "err 1\n" [live stdout 17.1s] "out 2\n" [live stderr] "err 2\n" [live stderr] "err 3\n" [live stdout 17.4s] "out 3\n" [live stdout 17.7s] "hi from env\n" # ``` What to know, in depth: - **Arguments are not a shell command.** `["echo", "$HOME"]` prints `$HOME` literally. For pipes, variables and `&&`, run `["sh", "-c", "…"]`. - **Callbacks** get each chunk as it arrives, as a binary String, on the thread that called `run`. Chunks are whatever the daemon sent, not lines. `on_status` gets the daemon's progress lines; none came here because the image was already present. The collected `stdout`/`stderr` in the result are UTF-8, with invalid bytes replaced. - **Env** is a hash or an array of `"K=V"` strings. Only `run` and `start_interactive` take it. - **Exit codes are results.** `r.exit_code` and `r.ok?` tell you how the command ended. An exception means the command could not run at all (see [StreamError](https://docs.kosa8.com/guides/rb-errors/#StreamError)). - **On the wire:** `POST /kosa8/v1/sandboxes/{id}/run` with `{"image","args","env"}`, answered with one JSON frame per line: `{"stream":"stdout","data":""}` … and a final `{"done":true,"ok":true,"exit":0}`. ### `start_container` and `exec`: state that carries over ``` cid = sb.start_container("alpine:latest", ["sleep", "3600"]) puts "cid: #{cid}" puts "containers: #{sb.containers.inspect}" ``` ``` cid: k3-bea6352b containers: [{"id"=>"k3-bea6352b", "image"=>"docker.io/library/alpine:latest", "status"=>"running", "ip"=>"10.88.0.4"}] ``` Then `sb.exec(cid, ["sh", "-c", "…"])` runs commands in it and returns the same `ExecResult`, with the same callbacks (no `env:`). The container's main command must keep running, which is why the examples use `sleep 3600` or a loop. For the raw stream, `exec_frames` yields every frame, including the last. It does not raise on an error frame, so it suits code that wants to see exactly what the daemon said: ``` k.exec_frames("doc-rb-guide", cid, ["sh", "-c", "echo a; echo b >&2; exit 4"]) { |f| p f } ``` ``` {"stream"=>"stdout", "data"=>"a\n"} {"stream"=>"stderr", "data"=>"b\n"} {"done"=>true, "ok"=>true, "exit"=>4} ``` ### Feeding input: `start_interactive`, `write_stdin`, `attach` **In plain words:** some programs read input, the way `cat` or a REPL does. Start the program with its input held open, send it the input, then collect what it printed. ``` icid = k.start_interactive("doc-rb-guide", "alpine:latest", ["sh", "-c", "cat | tr a-z A-Z"]) p k.write_stdin("doc-rb-guide", icid, "hello stdin\n") p k.attach("doc-rb-guide", icid, timeout: 30) icid2 = k.start_interactive("doc-rb-guide", "alpine:latest", ["wc", "-c"]) p k.write_stdin("doc-rb-guide", icid2, ["abc", "def", "\n"]) # an Enumerable: sent chunked p k.attach("doc-rb-guide", icid2, timeout: 30) icid3 = k.start_interactive("doc-rb-guide", "alpine:latest", ["sh", "-c", "wc -l"]) File.write("lines.txt", "1\n2\n3\n") File.open("lines.txt") { |f| p k.write_stdin("doc-rb-guide", icid3, f) } # an IO: streamed p k.attach("doc-rb-guide", icid3, timeout: 30) ``` ``` {"ok"=>true} # {"ok"=>true} # {"ok"=>true} # ``` **One write is the whole of stdin.** The daemon closes the program's input as soon as the body of a `write_stdin` is finished. So the gem allows one `write_stdin` per container: since 0.1.1 a second one raises instead of pretending to work. With 0.1.1, through the handle (which also has these methods since 0.1.1): ``` icid = sb.start_interactive("alpine:latest", ["cat"]) p sb.write_stdin(icid, "one\n") begin sb.write_stdin(icid, "two\n") rescue Kosa8::Error => e puts "second write_stdin: #{e.class}: #{e.message}" end p sb.attach(icid, timeout: 30) ``` ``` {"ok"=>true} second write_stdin: Kosa8::Error: stdin of container k1-b998c4e0 in doc-rb-v11 was already sent and is closed: the daemon treats one write_stdin body as the whole of stdin. Pass an IO or an Enumerable to stream input # ``` (With 0.1.0 the second call answered `{"ok"=>true}` and `"two"` was silently lost.) To send input over time, pass one IO or Enumerator that yields chunks as you produce them; the gem sends it chunked, and the program reads each chunk as it arrives. The client remembers which containers it has written to, so a different `Kosa8::Client` object would not stop a second write; and the container is marked before the request goes out, so if the first write fails, start a new container rather than retrying. `attach` follows the container until it exits and returns its output; `tty: true` on `start_interactive` asks for a terminal (not tested here). ## Snapshots and restore **In plain words:** a snapshot is a saved copy of a running sandbox, taken without stopping it. Restoring one starts a *new* sandbox exactly where the copy was, with its programs still running. ``` s = sb.snapshot(id: "doc-rb-g2-snap") puts "snapshot keys: #{s.keys.inspect} in #{s['snapshot_ms']}ms" r = k.restore_snapshot("doc-rb-g2-snap", name: "doc-rb-g2-copy") puts "restore keys: #{r.keys.inspect} restore_ms=#{r['restore_ms']} parent=#{r['sandbox']['parent']}" puts "copy exec: #{k.sandbox('doc-rb-g2-copy').exec(cid, ['cat', '/n']).stdout.inspect}" ``` ``` snapshot keys: ["snapshot", "snapshot_ms"] in 365ms restore keys: ["restore_ms", "sandbox"] restore_ms=434 parent=doc-rb-g2 copy exec: "0\n" ``` The copy has the original's containers, running, under the same ids: the container id `cid` from the original worked in the copy. A snapshot record carries its size (`size_bytes`: 100,667,392 for this idle 512 MiB sandbox) and where its files are. Snapshot ids must be unused: taking `doc-rb-g2-snap` again raised `ConflictError` (`snapshot already exists: doc-rb-g2-snap (choose another id, or delete the existing snapshot first)`). `k.delete_snapshot(id)` answered `{"encrypted"=>false, "ok"=>true, "removed"=>"doc-rb-g2-snap"}`. Deleting a sandbox does not delete snapshots you took with `snapshot`; only its checkpoints. ## Fork **In plain words:** make several live copies of a running sandbox at once. Each copy starts exactly where the original was and then goes its own way. ``` f = sb.fork(2, prefix: "doc-rb-g2-f") puts JSON.generate(f) ``` ``` {"count":2,"fork_ms":3432,"forks":[{"id":"doc-rb-g2-f1","name":"doc-rb-g2-f1",…,"state":"running","parent":"doc-rb-g2",…},{"id":"doc-rb-g2-f2",…}],"snapshot":"doc-rb-g2-fork-1793688072352650000"} ``` Copies are named prefix plus a number. Each one is an ordinary sandbox: run in it, delete it. `snapshot` is the id of the snapshot the copies were made from; once both copies were deleted, it had gone too. Disks are copy-on-write, so a copy costs disk only for what it changes, but each running copy uses its own memory. ## Explore: try several ideas, keep the best **In plain words:** give `explore` two or more shell commands. It forks the sandbox once per command, runs them all at the same time, keeps the copy where the fastest successful one ran, and deletes the others. ``` ex = sb.explore(["sleep 2; echo slow", "echo fast", "echo broken; exit 1"], prefix: "doc-rb-g2-e") p ex.winner, ex.removed ex.branches.each { |b| p b } ``` ``` "doc-rb-g2-e2" 2 # # # ``` Afterwards the parent and `doc-rb-g2-e2` were left; the winner is yours to use and to delete. In depth: - "Successful" means exit code 0 and no error; the winner is the one with the smallest `duration_ms`. `e3` was quicker but failed, so it lost. - Each variant runs as `sh -c VARIANT` in `image:` (default `alpine:latest`, pulled first unless `pull: false`), with a `timeout:` of 120 seconds per read by default. - Branches run in Ruby threads, one per variant. A freshly made copy can refuse its first connection, so connection-level failures are retried, four attempts in all. - `keep_all: true` deletes nothing; `keep: "name"` keeps that branch instead of the fastest. If nothing succeeds, the winner is `""` and every branch is deleted. - The parent sandbox is never changed. Exact rules, and two quirks, are in the [API reference](https://docs.kosa8.com/guides/rb-api/#explore). ## Checkpoints and rewind **In plain words:** a checkpoint is an undo point. Take one before something risky; if it goes wrong, `rewind` puts the sandbox back exactly as it was, programs still running, under the same name. ### Shared with the command line Checkpoints live in `~/.kosa8/checkpoints.json`, the file the `kosa8` CLI, its MCP server and the Mac app use. A checkpoint taken in Ruby shows in `kosa8 checkpoint ls`, and one taken with the CLI shows in Ruby: ``` c1 = sb.checkpoint(label: "base") puts "ruby checkpoint: #{c1['checkpoint'].inspect} evicted=#{c1['evicted'].inspect}" puts "CLI sees:\n" + `kosa8 checkpoint ls doc-rb-g3 2>&1` puts "CLI checkpoint: " + `kosa8 checkpoint doc-rb-g3 --label deps 2>&1` puts "ruby sees: #{sb.checkpoints.map { |c| [c['id'], c['label']] }.inspect}" rw = sb.rewind(to: "BASE") puts "rewind: checkpoint=#{rw['checkpoint']['id']} dropped=#{rw['dropped'].inspect} restore_ms=#{rw['restore_ms']}" puts "after rewind ruby sees: #{sb.checkpoints.map { |c| c['id'] }.inspect}" ``` ``` ruby checkpoint: {"id"=>"doc-rb-g3-ckpt-1", "sandbox"=>"doc-rb-g3", "label"=>"base", "created"=>"2026-11-03T06:46:25.129669000Z"} evicted=[] CLI sees: CHECKPOINT WHEN TAKEN WHY *doc-rb-g3-ckpt-1 just now you base * is where `kosa8 rewind doc-rb-g3` goes CLI checkpoint: checkpoint doc-rb-g3-ckpt-2 (deps) saved in 902ms rewind with `kosa8 rewind doc-rb-g3` ruby sees: [["doc-rb-g3-ckpt-1", "base"], ["doc-rb-g3-ckpt-2", "deps"]] rewind: checkpoint=doc-rb-g3-ckpt-1 dropped=["doc-rb-g3-ckpt-2"] restore_ms=421 after rewind ruby sees: ["doc-rb-g3-ckpt-1"] ``` ### How it works, in depth - **A stack per sandbox.** Ids are `-ckpt-`, numbered past the highest used. Each is a real snapshot with that id. At most 10 are kept (`Kosa8::MAX_CHECKPOINTS_PER_SANDBOX`): the eleventh evicts the oldest, whose snapshot is deleted only once the new one exists. `checkpoint` returns the record, the evicted ids and the snapshot answer. - **Rewind** goes to the latest checkpoint, or to `to:`, an id or a label (labels ignore case: `"BASE"` found `base`). It deletes the sandbox, restores the checkpoint's snapshot under the same name, and drops every checkpoint taken after the target, deleting their snapshots, because they describe a future that no longer happened. The target itself stays, so you can rewind to it again. - **The file and its lock.** Every change rereads the file, edits it and writes it back atomically (a temporary file, mode 0600, renamed into place), under an exclusive `flock` on `~/.kosa8/checkpoints.json.lock`, the same lock the CLI takes. So the gem and the CLI never overwrite each other's changes. Inside one process a `Mutex` serialises threads too. The lock is waited for up to 10 seconds, then `CheckpointError` is raised (`timed out after 10s waiting for …/checkpoints.json.lock; another kosa8 process is holding it`); change it with `FileCheckpointStore.new(path, lock_timeout: 30)`. (0.1.0 waited for ever.) A missing or corrupt file reads as empty rather than failing. - **Clean-up.** `delete` removes the sandbox's stack and its snapshots (`"checkpoints_dropped"=>1` above), unless `keep_checkpoints: true`. - **Other stores.** `Kosa8::Client.new(checkpoint_store: Kosa8::FileCheckpointStore.new("/path/checkpoints.json"))` keeps a separate file (no longer shared with the CLI). `Kosa8::MemoryCheckpointStore.new` keeps them in memory, for tests. Any object with `load` and `update { |data| … }` works. **A rewind that fails halfway** The sandbox is deleted before the restore, because the restored machine needs its name. If the restore then fails (on a Mac, most often because the screen is locked), the sandbox is gone and the checkpoint is intact. `rewind` raises `Kosa8::CheckpointError` whose message says so (`restore failed and doc-rb-g3 no longer exists (it was deleted to free its name for the restore): … — the checkpoint is intact; recover with restore_snapshot("doc-rb-g3-ckpt-1", name: "doc-rb-g3")`); run that call once the cause is fixed. `e.cause` holds the daemon's error. ## Threads **In plain words:** you can do several things at once from one Ruby program, such as running commands in many sandboxes in parallel, with one shared client. ``` t0 = Time.now rs = 4.times.map { |i| Thread.new { sb.run("alpine:latest", ["sh", "-c", "sleep 2; echo thread #{i}"]).stdout.strip } }.map(&:value) puts "threads: #{rs.inspect} in #{(Time.now - t0).round(1)}s" ``` ``` threads: ["thread 0", "thread 1", "thread 2", "thread 3"] in 3.6s ``` Four 2-second commands took 3.6 seconds in all, not 8. In depth: - The client keeps no connection open: every request opens its own socket connection, so one `Kosa8::Client` can be used from many threads. Ruby's global lock does not get in the way, because the threads spend their time waiting on the socket. - Callbacks run on the thread that made the call. If several threads print, guard shared state yourself (a `Mutex` or a `Queue`). Checkpoints of the *same* sandbox taken at the same moment can pick the same next id. Since 0.1.2 the one the daemon refuses steps to the next number and tries again (up to 10 times), so all of them succeed. Three threads, twice, against v0.1.2: ``` 2.times do |round| rs = 3.times.map { |i| Thread.new { sb.checkpoint(label: "r#{round}t#{i}")["checkpoint"]["id"] } }.map(&:value) puts "round #{round}: #{rs.inspect}" end puts "stack: #{sb.checkpoints.map { |c| c['id'] }.inspect}" ``` ``` round 0: ["doc-rb-race-ckpt-3", "doc-rb-race-ckpt-2", "doc-rb-race-ckpt-1"] round 1: ["doc-rb-race-ckpt-6", "doc-rb-race-ckpt-4", "doc-rb-race-ckpt-5"] stack: ["doc-rb-race-ckpt-1", "doc-rb-race-ckpt-2", "doc-rb-race-ckpt-3", "doc-rb-race-ckpt-4", "doc-rb-race-ckpt-5", "doc-rb-race-ckpt-6"] ``` - The test also logged each refusal: six 409s were resolved this way. In 0.1.0 the loser raised `ConflictError`; 0.1.1 retried only when the winner's snapshot already showed in `list_snapshots`, which it often did not yet, and in our test one of three threads still failed. Different sandboxes never collide. - `explore` starts its own threads; its result is ready when it returns. ## Timeouts **In plain words:** a timeout is how long the gem waits for the daemon before giving up. By default, a running command is waited for as long as it takes; you set a timeout only when you want your program to stop waiting. The gem uses Ruby's `Net::HTTP`. When you set `timeout:` (on the client, or on one call, which wins), it becomes the read timeout, and the connect timeout is the smaller of it and 30 seconds. When you set none: - **Streaming calls** (`run`, `exec`, `exec_frames`, `attach`) have no read timeout, since 0.1.1: they wait as long as the command runs, however long it is silent. - **Every other call** keeps Net::HTTP's defaults: 60 seconds to connect and 60 seconds for each read. Creating a sandbox, snapshots, forks and restores usually answer within seconds. During a streaming call, each read waits for the next piece of output, so a timeout you set restarts whenever the command prints: it measures the longest silence, not the whole run. ``` sb.run("alpine:latest", ["sleep", "30"], timeout: 3) # gives up after 3 silent seconds r = sb.run("alpine:latest", ["sh", "-c", "sleep 70; echo done"]) # no timeout: waits ``` What those two printed, through the test script's own `puts` lines (0.1.1): ``` TimeoutError: POST /kosa8/v1/sandboxes/doc-rb-v11/run timed out talking to unix:///Users/dhruvit/.kosa8/run/kosa8.sock: Net::ReadTimeout with # containers after timeout: [["docker.io/library/alpine:latest", "exited"], ["docker.io/library/alpine:latest", "running"], ["docker.io/library/alpine:latest", "running"]] 70s silent run, no timeout: exit 0 "done\n" after 71s ``` - **A timeout stops the waiting, not the command.** After the 3-second timeout, the `sleep 30` container was still listed as `running` (the other running one is the example's long-lived shell). The daemon has no way to cancel a run in progress. Stop it by deleting or rewinding the sandbox, or make commands end on their own (`timeout 30 cmd` inside the sandbox). - **In 0.1.0**, streaming calls kept the 60-second default, so the 70-second silent run above failed with `TimeoutError` at 60 s unless you passed a larger `timeout:`. If you must run on 0.1.0, pass one, such as `Kosa8::Client.new(timeout: 3600)`. - `Kosa8::TimeoutError` is the gem's own class, not Ruby's `Timeout::Error`. ## When your own callback fails An exception raised inside your `on_stdout`, `on_stderr` or `on_status` callback (or an `exec_frames` block) ends the call and comes out as itself, since 0.1.1: ``` sb.run("alpine:latest", ["echo", "hi"], on_stdout: ->(b) { raise IOError, "closed stream (mine)" }) ``` ``` callback: IOError: closed stream (mine) ``` In 0.1.0 an `IOError` or system error there was relabelled `Kosa8::ConnectionError`, as if the daemon had gone away. The command in the sandbox is not stopped either way. ## Approvals and policy **In plain words:** a company can set rules for kosa8 (an org policy): which images may be pulled, whether a sandbox may have open network, and actions that named people must approve first. When a rule says no, the gem raises an exception that carries the daemon's reason. Methods that can be gated take `approval:`, an approval id, sent as the `X-Kosa8-Approval` header. A refusal that an approval would lift is `Kosa8::ApprovalRequiredError`, whose `approval` hash says why and who can sign; a plain refusal is `Kosa8::PolicyError`. Neither was reproduced for this page (it needs a policy installed). The flow is on [Company rules](https://docs.kosa8.com/guides/governance/) and [Ruby errors](https://docs.kosa8.com/guides/rb-errors/#ApprovalRequiredError). ## Recipes ### An agent loop with undo An AI agent proposes commands one at a time. Take a checkpoint before each, run it, judge the result, and rewind when a step does harm. Here the "agent" is a fixed list, and its second step is destructive: ``` require "kosa8" # The commands an AI agent proposes, one per step. In a real loop these come from a model. STEPS = [ "mkdir -p /work && echo 'print(6 * 7)' > /work/app.py", "rm -rf /work", # a bad step: it destroys the work "cat /work/app.py && echo step3 ok", ].freeze k = Kosa8::Client.new(actor: "agent") sb = k.sandbox("doc-rb-agent") k.create_sandbox(name: sb.id, egress: "deny", mem_mb: 512) begin sb.pull("alpine:latest") shell = sb.start_container("alpine:latest", ["sh", "-c", "while :; do sleep 3600; done"]) STEPS.each_with_index do |cmd, i| sb.checkpoint(label: "before step #{i + 1}") r = sb.exec(shell, ["sh", "-c", cmd], timeout: 300) # Judge the step. Here: a step must leave /work/app.py in place. check = sb.exec(shell, ["test", "-f", "/work/app.py"]) if r.ok? && check.ok? puts "step #{i + 1} kept: #{cmd}" else back = sb.rewind # the latest checkpoint: just before this step puts "step #{i + 1} undone (#{back['checkpoint']['label']}, #{back['restore_ms']}ms): #{cmd}" end end puts "final: #{sb.exec(shell, ['cat', '/work/app.py']).stdout.strip}" puts "checkpoints: #{sb.checkpoints.map { |c| c['label'] }.inspect}" ensure out = sb.delete puts "cleanup: #{out.inspect}" end ``` ``` $ ruby agent.rb step 1 kept: mkdir -p /work && echo 'print(6 * 7)' > /work/app.py step 2 undone (before step 2, 388ms): rm -rf /work step 3 kept: cat /work/app.py && echo step3 ok final: print(6 * 7) checkpoints: ["before step 1", "before step 2", "before step 3"] cleanup: {"ok"=>true, "checkpoints_dropped"=>3} ``` Because a rewind brings back running programs, the `shell` container id keeps working after it. `actor: "agent"` makes the audit log show these calls as an agent's. (This output is from a run against the v0.1.1 source; 0.1.2 changed only how simultaneous checkpoints pick ids, which this one-at-a-time loop never does. That run also did a 70-second silent `run` before clean-up, which is left out of the listing.) ### Always clean up A sandbox keeps running, and using memory, until it is deleted, even after your program exits. Put the work in `begin … ensure` right after the create, as above, so an exception (or Ctrl-C, which Ruby raises as `Interrupt`) still deletes it. For clean-up that must not itself fail: ``` def with_sandbox(k, name, **opts) k.create_sandbox(name: name, **opts) sb = k.sandbox(name) begin yield sb ensure begin sb.delete rescue Kosa8::NotFoundError # already gone (a failed rewind, or someone ran `kosa8 sandbox rm`) rescue Kosa8::Error => e warn "could not delete #{name}: #{e.message}" end end end with_sandbox(k, "doc-rb-job", egress: "deny") { |sb| sb.pull("alpine:latest"); puts sb.run("alpine:latest", ["true"]).ok? } begin with_sandbox(k, "doc-rb-job2", egress: "deny") { |sb| raise "boom" } rescue RuntimeError => e puts "raised #{e.message}; left: #{k.list_sandboxes.map { |x| x['name'] }.grep(/doc-rb-job/).inspect}" end ``` ``` true raised boom; left: [] ``` The second block raised inside the sandbox's block; the sandbox was still deleted, and the exception still reached the caller. Leftovers from crashed runs show in `k.list_sandboxes` and `k.list_snapshots` (or `kosa8 sandbox ls` and `kosa8 snapshot ls`). Fork snapshots are named `-fork-…`. ### Install packages through an allow-list ``` k.create_sandbox(name: "doc-rb-py", egress_allow: ["pypi.org", "files.pythonhosted.org"]) sb = k.sandbox("doc-rb-py") sb.pull("python:3.12-alpine") shell = sb.start_container("python:3.12-alpine", ["sleep", "3600"]) r = sb.exec(shell, ["pip", "install", "requests"], timeout: 600, on_stdout: ->(b) { $stdout.write(b) }) ``` Only the two package hosts are reachable; anything the package tries to fetch from elsewhere is refused and counted in `sb.egress["stats"]`. (This recipe was not run for this page; the allow-list behaviour it relies on was, above.) **Next** [Errors and troubleshooting](https://docs.kosa8.com/guides/rb-errors/): every exception, with real messages, and what to do. [API reference](https://docs.kosa8.com/guides/rb-api/): every method and keyword. [Securing an agent](https://docs.kosa8.com/guides/secure-agents/): the wider picture. --- Source: https://docs.kosa8.com/guides/rb-guide/ # Ruby errors and troubleshooting Every exception the gem raises is a `Kosa8::Error`, and one that comes from the daemon keeps the daemon's own words: what went wrong, and what to do about it. This page lists each class with a real message, then the problems you are likely to meet and how to fix them. **How this page was tested** The page documents the `kosa8` gem 0.1.2. The messages below were printed by the gem's source at tag `v0.1.1` or `v0.1.2` (loaded with `ruby -I lib`; 0.1.2 changed only the checkpoint retry, so the other messages are the same in both; system Ruby 2.6.10) against a real kosa8 daemon 0.9.36 on macOS 26.0.1, Apple silicon, unless a section says otherwise. The daemon's own messages were the same as with 0.1.0. The `ProtocolError` and lock-timeout messages came from a fake daemon and a held lock in a test script, not from kosa8 misbehaving. `PolicyError`, `ApprovalRequiredError` and `UnsupportedError` were not reproduced: the first two need an org policy installed, the last an unsupported machine. Their sections are taken from the gem's source and say so. The locked-screen messages are quoted from the daemon's 0.9.36 text, not from a run of the gem. ## What this is, in plain words When something goes wrong, Ruby **raises an exception**: an object that says what happened. Your program can **rescue** it and decide what to do, or let it stop the program with a message. The gem's exceptions come in families, so you can rescue one precise problem ("that sandbox does not exist") or a whole family ("anything from kosa8") with one line. There is one rule worth learning first: **a command that fails is not an exception**. If your command runs and exits with code 1, you get a normal result with `exit_code` 1. Exceptions are for when kosa8 could not do what you asked at all: the daemon is not running, the sandbox does not exist, the image is missing, a rule forbids it. ## The hierarchy ``` StandardError └─ Kosa8::Error anything this gem raises; also checks made in the gem ├─ Kosa8::ConnectionError the daemon could not be reached ├─ Kosa8::TimeoutError a connect or read took longer than the timeout ├─ Kosa8::StreamError a run, exec or attach ended with an error, not an exit code ├─ Kosa8::CheckpointError checkpoint bookkeeping: none to rewind to, bad name, lock, failed rewind ├─ Kosa8::ProtocolError the daemon's output stream was not valid NDJSON (since 0.1.1) └─ Kosa8::ApiError the daemon answered with an HTTP error (500, 503, … land here) ├─ Kosa8::BadRequestError 400 ├─ Kosa8::AuthError 401 ├─ Kosa8::PolicyError 403 │ └─ Kosa8::ApprovalRequiredError 403 that a signed approval would lift ├─ Kosa8::NotFoundError 404 ├─ Kosa8::ConflictError 409 └─ Kosa8::UnsupportedError 501 ``` All inherit from `StandardError`, so a bare `rescue => e` catches them. Rescue the more specific class first: Ruby uses the first `rescue` clause that matches, and `ApprovalRequiredError` is also a `PolicyError`, which is also an `ApiError`. `Kosa8::TimeoutError` is the gem's own class and is not Ruby's `Timeout::Error`. ## Fields | Reader | On | Meaning | |---|---|---| | `message` | all | For a daemon error: `" ()"`, the way the CLI prints it. | | `status` | `ApiError` and below | The HTTP status, an Integer. `nil` for the other classes. | | `daemon_message` | daemon errors, stream errors | The daemon's text alone, without the remediation. | | `remediation` | daemon errors | The daemon's advice: what to do next. `nil` when it gave none. | | `http_method`, `path` | daemon, connection, timeout and protocol errors | The request that failed: `"GET"`, `"/kosa8/v1/health"`. | | `body` | daemon errors | The parsed response body (a hash, or the text). | | `stdout`, `stderr` | `StreamError` | Output produced before the failure. | | `approval` | `ApprovalRequiredError` | The daemon's refusal: `why`, and who can sign. | | `cause` | a failed `rewind` | Ruby's standard `Exception#cause`: the daemon's error that made the restore fail. | In 0.1.0 the HTTP method was read with `e.method`, which hid Ruby's `Object#method` (so `e.method(:message)` raised `ArgumentError`). Since 0.1.1 it is `e.http_method`, and `e.method(:message)` works again: ``` http_method="GET" method(:message)=# ``` ## See them for yourself This script provokes most of the errors without needing a sandbox. The `AuthError` line sends one harmless request with a made-up agent token; nothing is changed. ``` require "kosa8" k = Kosa8::Client.new def show(label) yield puts "#{label}: no error" rescue Kosa8::Error => e puts "#{label}: #{e.class} status=#{e.status.inspect}" puts " message: #{e.message}" puts " daemon_message: #{e.daemon_message.inspect}" if e.daemon_message puts " remediation: #{e.remediation.inspect}" if e.remediation puts " request: #{e.http_method} #{e.path}" if e.http_method end show("ConnectionError") { Kosa8::Client.new(socket_path: "/tmp/k8rb-none.sock").health } show("NotFoundError") { k.sandbox_egress("doc-rb-missing") } show("BadRequestError") { k.create_sandbox(name: "doc-rb-bad", egress_allow: ["*.com"]) } show("AuthError") { Kosa8::Client.new(agent_token: "not-a-real-token").list_sandboxes } show("Error (client check)") { k.create_sandbox(egress: "any", egress_allow: ["x.io"]) } show("CheckpointError none") { k.rewind("doc-rb-never") } show("CheckpointError name") { k.checkpoint("../etc") } show("TimeoutError remote") { Kosa8::Client.new(host: "https://10.255.255.1:7440", timeout: 2).health } ``` The run this output comes from had a few more cases; their messages are under each class below, and they are cut from this listing. ``` $ ruby errors.rb ConnectionError: Kosa8::ConnectionError status=nil message: kosa8 daemon unreachable at unix:///tmp/k8rb-none.sock: No such file or directory - connect(2) for /tmp/k8rb-none.sock (is kosa8 installed and its daemon running? start it with `kosa8 daemon start`) request: GET /kosa8/v1/health NotFoundError: Kosa8::NotFoundError status=404 message: no such sandbox: doc-rb-missing (list sandboxes with `kosa8 sandbox ls`) daemon_message: "no such sandbox: doc-rb-missing" remediation: "list sandboxes with `kosa8 sandbox ls`" request: GET /kosa8/v1/sandboxes/doc-rb-missing/egress BadRequestError: Kosa8::BadRequestError status=400 message: egress allow "*.com": com is a public suffix, under which anyone can register a name, so the wildcard would admit strangers' hosts; allow a domain below it, as in *.example.com daemon_message: "egress allow \"*.com\": com is a public suffix, under which anyone can register a name, so the wildcard would admit strangers' hosts; allow a domain below it, as in *.example.com" request: POST /kosa8/v1/sandboxes AuthError: Kosa8::AuthError status=401 message: agent token is not recognised; mint one with `kosa8 agent token create NAME` daemon_message: "agent token is not recognised; mint one with `kosa8 agent token create NAME`" request: GET /kosa8/v1/sandboxes Error (client check): Kosa8::Error status=nil message: egress_allow already limits the network to the hosts it lists; do not also set egress CheckpointError none: Kosa8::CheckpointError status=nil message: no checkpoints for "doc-rb-never" — take one with checkpoint or `kosa8 checkpoint doc-rb-never` CheckpointError name: Kosa8::CheckpointError status=nil message: invalid sandbox name "../etc" TimeoutError remote: Kosa8::TimeoutError status=nil message: GET /kosa8/v1/health timed out talking to https://10.255.255.1:7440: execution expired request: GET /kosa8/v1/health ``` ## Each class ### `Kosa8::ConnectionError` **In plain words:** the gem could not reach kosa8 at all. **When:** the request never got to a daemon: no socket file, a socket with nothing listening, a socket path too long for the operating system, a remote host that refuses or cannot be resolved, a TLS failure. Technically: any `SystemCallError`, `IOError`, `SocketError` or `OpenSSL::SSL::SSLError` while sending or reading. The hint about `kosa8 daemon start` is added for "no such file" and "connection refused". See the real message above. **Handle:** start the daemon (`kosa8 daemon start`) or fix the address (`KOSA8_HOST`, `socket_path:`). If it happened before the request was sent, nothing was done and a retry is safe. Since 0.1.1, an exception raised inside your own `on_stdout`/`on_stderr`/`on_status` callback comes out as itself, not as a `ConnectionError`. In our test a callback that raised `IOError, "closed stream (mine)"` made `run` raise exactly that: `IOError: closed stream (mine)`. ### `Kosa8::TimeoutError` **In plain words:** kosa8 took longer to answer than the gem was told to wait. **When:** Net::HTTP's connect or read timeout passed (`Net::OpenTimeout`, `Net::ReadTimeout`). Which timeout applies: **You passed `timeout:`** (on the call or the client): it is the read timeout, and the connect timeout is the smaller of it and 30 s. During a `run` the clock restarts each time the command prints, so it fires when the command is *silent* that long. `run(…, ["sleep", "30"], timeout: 3)` printed: ``` POST /kosa8/v1/sandboxes/doc-rb-v11/run timed out talking to unix:///Users/dhruvit/.kosa8/run/kosa8.sock: Net::ReadTimeout with # ``` - **No timeout, streaming call** (`run`, `exec`, `exec_frames`, `attach`): no read timeout at all since 0.1.1, so it waits as long as the command runs. A 70-second silent `sleep 70; echo done` returned `exit 0 "done\n" after 71s`. (In 0.1.0 the same call raised this error at 60 s.) - **No timeout, any other call**: Net::HTTP's defaults, 60 s to connect and 60 s per read. A remote worker that never answers gives `GET /kosa8/v1/health timed out talking to https://10.255.255.1:7440: execution expired` (here with `timeout: 2`). **Handle:** pass a `timeout:` only when you want the client to give up ([Timeouts](https://docs.kosa8.com/guides/rb-guide/#timeouts)). The command in the sandbox is *not* stopped by a timeout: in our test the `sleep 30` container was still listed as running afterwards. Do not blindly retry a `run` after a timeout, or you will have two copies running. ### `Kosa8::StreamError` **In plain words:** the command could not be started, or the stream of its output broke. **When:** `run`, `exec` or `attach` began streaming and the daemon ended the stream with an error frame instead of an exit code, or the stream stopped with no final frame. Real messages: ``` image "docker.io/library/python:3.12-alpine" not found in engine: image "docker.io/library/python:3.12-alpine": not found failed to create shim task: OCI runtime create failed: executable file `python3` not found in $PATH: No such file or directory no such container: k99-nope ``` The truncated case reads `stream ended unexpectedly (no final frame)`. `status` is `nil` (the HTTP answer itself was a success); `daemon_message` is the frame's error text. **Handle:** pull the image into the sandbox first (`sb.pull`), check the container id (`sb.containers`), or check the program exists in the image, or run it through `sh -c`, which turns a missing program into an ordinary exit code 127. `e.stdout` and `e.stderr` hold any output produced before the failure. `exec_frames` never raises this: it hands you the error frame, `{"done"=>true, "error"=>"no such container: k99-nope"}`. ### `Kosa8::ProtocolError` **In plain words:** the answer that came back was not in the format kosa8 speaks, so the gem cannot read it. You should not see this from a real kosa8 daemon. **When** (since 0.1.1): a line of a `run`, `exec`, `exec_frames` or `attach` stream is not valid JSON, or its data is not valid base64. Usually something other than kosa8 is answering at that address: a proxy's error page, a different service on the port. Provoked with a fake server that answered a `run` with the line `this is not json`: ``` POST /kosa8/v1/sandboxes/sb/run: the daemon sent a frame that is not valid NDJSON (767: unexpected token at 'this is not json'): "this is not json" ``` **Handle:** check what is listening at `KOSA8_HOST` or the socket; the message quotes the first 200 characters of the bad line. In 0.1.0 this surfaced as Ruby's `JSON::ParserError`. ### `Kosa8::CheckpointError` **In plain words:** a problem with the undo list, found in your program before or after talking to kosa8. **When:** - nothing to rewind to: `no checkpoints for "doc-rb-never" — take one with checkpoint or `kosa8 checkpoint doc-rb-never - a `to:` that matches no id or label: `no checkpoint "nope" for sandbox "doc-rb-err"` - a sandbox name that could escape the store (empty, starting with a dot, containing `/` or `\`): `invalid sandbox name "../etc"` - the checkpoint file's lock held by another process for longer than `lock_timeout` (10 s by default; since 0.1.1, before which it waited for ever). Provoked by holding the lock in the test script, with `lock_timeout: 2`: `timed out after 2s waiting for /tmp/demo/checkpoints.json.lock; another kosa8 process is holding it` - a rewind whose restore failed after the sandbox was deleted: `restore failed and NAME no longer exists (it was deleted to free its name for the restore): — the checkpoint is intact; recover with restore_snapshot("NAME-ckpt-N", name: "NAME")`. (From the source; not reproduced.) Here `e.cause` is the daemon's error. **Handle:** take a checkpoint first, or list them with `sb.checkpoints`. For the lock: find the other kosa8 process (a CLI command, another program) that is stuck, or retry. For the failed rewind: fix the cause (on a Mac, unlock the screen) and run the `restore_snapshot` call from the message. ### `Kosa8::Error` (itself) **In plain words:** the gem refused your request before sending it, because it could never work or would be unsafe. **When:** a check in the gem. Real messages: ``` egress_allow already limits the network to the hosts it lists; do not also set egress an egress allow-list needs at least one host; use egress: "deny" for no network actor must be human, agent or tool, not "root" refusing to talk to 10.0.0.5:7440 over plain http: the token and every request would cross the network unencrypted. Use https://10.0.0.5:7440 (a worker behind a TLS-terminating tunnel or proxy), or pass insecure: true / KOSA8_INSECURE=1 if this link is already private and trusted KOSA8_HOST scheme "ftp" is not supported; use http://, https://, tcp:// or unix:// give at least two variants; with one there is nothing to compare stdin of container k1-b998c4e0 in doc-rb-v11 was already sent and is closed: the daemon treats one write_stdin body as the whole of stdin. Pass an IO or an Enumerable to stream input ``` The last one (since 0.1.1) comes from a second `write_stdin` to the same container through the same client. In 0.1.0 the daemon answered `{"ok"=>true}` and the input was silently lost. Others in the source: `KOSA8_HOST "…" names no host`, and from `explore`, `forked N branches for M variants; see list_sandboxes for what was created` and `keep X is not one of the branches (…); all of them were kept`. **Handle:** fix the call; the message says what is wrong. Nothing was sent, except for the two `explore` messages, which come after the fork: the branches exist, and `list_sandboxes` shows them. ### `Kosa8::ApiError` (other statuses) **In plain words:** kosa8 tried and something went wrong on its side. **When:** any HTTP error without a closer class, usually 500, or 503 for a locked Mac. Real messages, both 500: ``` guest not ready on vsock port 1024 after 25s (run `kosa8 doctor`; check ~/.kosa8/log/) pull : manifest: unauthorized after token refresh (check the image reference and network) ``` The first came from `create_sandbox` on a heavily loaded Mac: the new machine did not answer in time. A retry a minute later succeeded. The second is what pulling an image that does not exist looked like (`docker.io/library/does-not-exist-kosa8:nope`). On a Mac with the screen locked, every restore (`rewind`, `fork`, `explore`, `restore_snapshot`) fails with a 503 whose message begins `macOS blocks restoring a VM while the screen is locked; unlock and retry`. **Handle:** read the message and its remediation. `kosa8 doctor` checks the machine, and `~/.kosa8/log/kosa8d.log` has the daemon's side. A 500 from a busy machine is often worth one retry; a 500 from a bad image reference is not. ### `Kosa8::BadRequestError` (400) **When:** the daemon refused a value, most often an egress rule (real message in the script output above). **Handle:** fix the input; the same request fails the same way every time. ### `Kosa8::AuthError` (401) **When:** an agent token was sent and is not valid (real message above), or a remote worker's bearer token is missing or wrong. A bad agent token is never treated as anonymous. **Handle:** mint a token with `kosa8 agent token create NAME`, or set `KOSA8_TOKEN` / `token:` (remember that `KOSA8_TOKEN` is ignored when you pass `host:` in code). See [Agent identity](https://docs.kosa8.com/guides/agent-identity/). ### `Kosa8::PolicyError` (403) **When:** the org policy forbids the action outright: a registry not on the allow-list, a label rule, open network where the policy forbids it. Not reproduced for this page. **Handle:** do something the policy allows, or ask whoever manages it; `kosa8 policy show` prints the active policy. See [Company rules](https://docs.kosa8.com/guides/governance/). ### `Kosa8::ApprovalRequiredError` (403) **When:** the policy wants named people to approve this exact action, and the call carried no usable approval. The gem chooses this class when a 403's body has an `approval` hash with a String `why`. A subclass of `PolicyError`, so rescue it first. Not reproduced for this page. **Handle:** show `e.approval` to a person, wait for the approval id, then repeat the same call with `approval: id`. An approval is spent by the one call it covers. ``` begin sb.delete rescue Kosa8::ApprovalRequiredError => e warn "needs approval: #{e.approval['why']}" id = wait_for_approval_id(e.approval) # yours to write: ask a person sb.delete(approval: id) end ``` ### `Kosa8::NotFoundError` (404) **When:** no such sandbox or snapshot (real message above). **Handle:** on clean-up paths, usually ignore it: the thing is already gone. `get_sandbox` returns `nil` instead of raising. A container that does not exist is reported differently, as a `StreamError`. If `delete_sandbox` raises this, the checkpoint records for that name are left in the store; a later `delete_sandbox` of a sandbox with the same name removes them. ### `Kosa8::ConflictError` (409) **When:** the request conflicts with what exists. Real messages: ``` sandbox doc-rb-err already exists (choose another name, or remove the existing sandbox with `kosa8 sandbox rm NAME` (`kosa8 sandbox ls` lists them)) snapshot already exists: doc-rb-g2-snap (choose another id, or delete the existing snapshot first) ``` **Handle:** choose another name, or delete the old one first. If you see the snapshot message from `checkpoint`, two callers checkpointed the same sandbox at once and the gem ran out of retries. Since 0.1.2 it steps to the next id by itself, up to 10 times; in our test three threads checkpointing one sandbox at once, twice over, all succeeded (see [Threads](https://docs.kosa8.com/guides/rb-guide/#threads)). On 0.1.0 and 0.1.1 one of them could fail with `snapshot already exists: doc-rb-v11-ckpt-1 …` (that one is a real 0.1.1 message); retrying the call works. ### `Kosa8::UnsupportedError` (501) **When:** the machine cannot do this: no microVM support (sandboxes need macOS on Apple silicon or Linux with KVM), or a guest image too old for a feature. Not reproduced for this page. **Handle:** `kosa8 doctor` says what is missing. ## A handler that covers the cases ``` begin # ... kosa8 calls ... rescue Kosa8::ApprovalRequiredError => e ask_a_person(e.approval) # then retry with approval: id rescue Kosa8::PolicyError => e warn "refused by policy: #{e.daemon_message}" rescue Kosa8::NotFoundError # already gone rescue Kosa8::ConnectionError => e abort "kosa8 is not running: #{e.message}" rescue Kosa8::TimeoutError => e warn "gave up waiting (the command may still be running): #{e.message}" rescue Kosa8::StreamError => e warn "could not run: #{e.message}\npartial output: #{e.stdout}#{e.stderr}" rescue Kosa8::Error => e warn e.message # includes the daemon's remediation end ``` That block is a pattern, not a program: `ask_a_person` is yours to write. Order matters: subclasses before their parents, `Kosa8::Error` last. ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| | `ConnectionError` … `No such file or directory - connect(2)` | The daemon is not running, or `HOME` / `socket_path:` points somewhere else. | `kosa8 daemon start`, then `kosa8 daemon status`. If it will not start, `kosa8 doctor` and `~/.kosa8/log/kosa8d.log`. After a daemon restart, sandboxes are stopped: `sb.start`. | | `ConnectionError` on macOS with a deep `HOME` (CI, containers, test harnesses) | macOS limits a unix socket path to 104 bytes, and the socket lives at `$HOME/.kosa8/run/kosa8.sock`. | Use a shorter `HOME`, or a short symlink to it, for both the daemon and your program. (From the JavaScript tests; see [there](https://docs.kosa8.com/guides/js-troubleshooting/#socket).) | | `TimeoutError` after exactly 60 s from `run`/`exec`, with no timeout set | You are on 0.1.0, where streaming calls kept Net::HTTP's 60 s read timeout. | Use 0.1.1 or later, which waits as long as the command runs; or pass a `timeout:` longer than the command's longest silence. | | `TimeoutError` after the time you set | The command was silent for longer than `timeout:`. | Raise the timeout or leave it out. The command is still running in the sandbox. | | `TimeoutError` … `execution expired` to a remote host | Nothing answered at that address within the connect timeout. | Check `KOSA8_HOST`, the worker, and any firewall or tunnel. | | `StreamError`: `image "…" not found in engine` | Each sandbox has its own image store; this one does not have the image. | `sb.pull(image)` before `run`. `k.pull` pulls to the host, not the sandbox. | | `StreamError`: `executable file … not found in $PATH` | The program is not in that image. | Use an image that has it, or install it first; run via `["sh", "-c", …]` to get exit code 127 instead. | | `run` returns but `stdout` is empty and `exit_code` is 127 | The shell could not find the command. | As above. `r.stderr` says which. | | A shell feature (`$VAR`, `\|`, `>`) is printed literally | Arguments are passed to the program directly, with no shell. | `["sh", "-c", "echo $HOME \| tr a-z A-Z"]`. | | Files written in one `run` are gone in the next | Every `run` starts a fresh container from the image. | `start_container` once, then `exec` in it. | | `Kosa8::Error`: `stdin of container … was already sent and is closed` (0.1.0: a second write answers `{"ok"=>true}` and the program never sees it) | The daemon closes stdin when the first write's body ends. | Send all input in one call, or pass one IO or Enumerator that yields chunks as you produce them. If the first write itself failed, start a new container: the client has marked the old one as written. | | `CheckpointError`: `timed out after 10s waiting for …checkpoints.json.lock` | Another process holds the checkpoint file's lock (a stuck CLI command, another program). | Find and end it, or retry; `FileCheckpointStore.new(lock_timeout: 30)` waits longer. | | `ProtocolError`: `the daemon sent a frame that is not valid NDJSON` | Something other than the kosa8 daemon answered. | Check `KOSA8_HOST` / `socket_path:` and any proxy in between. | | The network works when it should not, or not when it should | The egress rule is not what you think. | `sb.egress` shows the policy enforced and counts of what passed and was denied; the daemon log names each refused host. | | `ApiError` 500: `guest not ready on vsock port 1024 after 25s` | The new machine did not answer in time, typically on a heavily loaded Mac. | Retry; free memory and CPU (fewer running sandboxes); `kosa8 doctor` if it persists. | | `ApiError` 503: `macOS blocks restoring a VM while the screen is locked` | Apple's Virtualization framework will not restore saved machines while the Mac is locked. Taking checkpoints still works. | Keep the Mac unlocked while restoring jobs run, or run them on Linux. If a rewind failed this way, run the `restore_snapshot(…)` from the `CheckpointError` message after unlocking. Details on [the JavaScript troubleshooting page](https://docs.kosa8.com/guides/js-troubleshooting/#locked). | | `ConflictError`: `sandbox … already exists` | The name is taken, often by a previous run that did not clean up. | `k.delete_sandbox(name)` or `kosa8 sandbox rm NAME`; put clean-up in `ensure` ([recipe](https://docs.kosa8.com/guides/rb-guide/#cleanup)). | | `CheckpointError`: `no checkpoints for "x"` right after taking one | The client uses a different checkpoint store (a `MemoryCheckpointStore`, another file, or another `HOME`). | Use the default store to share with the CLI; check `Dir.home`. | | `gem install kosa8` fails with a permissions error on macOS | The system Ruby's gem folder is not writable without `sudo`. | `gem install --user-install kosa8`, a Ruby version manager, or Bundler with `--path vendor/bundle`. Do not use `sudo`. | | Disk fills up | Every checkpoint, snapshot and fork saves memory (about 100 MB for an idle 512 MiB sandbox in our test) plus what its disk changes. | Delete what you no longer need; `k.list_snapshots` shows leftovers, including fork snapshots named `-fork-…`. At most 10 checkpoints are kept per sandbox. | **Next** [Ruby developer guide](https://docs.kosa8.com/guides/rb-guide/) for how each capability works, and the [API reference](https://docs.kosa8.com/guides/rb-api/) for every method and what it raises. For the daemon itself, [When something is wrong](https://docs.kosa8.com/guides/self-repair/). --- Source: https://docs.kosa8.com/guides/rb-errors/ # Ruby API reference Everything public in the `kosa8` gem, version 0.1.2: every class, method, keyword argument and default, what each returns and what it raises. Signatures are copied from the source in `lib/`. Keys in returned hashes are the daemon's own JSON names, as strings. **How this page was checked** Every signature was copied from `lib/kosa8/*.rb` at tag `v0.1.2` of the source (commit `2b5945e`), and the gem published on RubyGems (`gem install kosa8 -v 0.1.2`) was checked to contain the same files. The result shapes and timings quoted were printed by real calls to a kosa8 daemon 0.9.36 on macOS 26.0.1 (Apple silicon), from the system Ruby 2.6.10, with 0.1.1 or 0.1.2 where the behaviour changed in those versions and 0.1.0 where it did not. `mounts:`, `capture:`, `broker:`, `encrypt:`, `allow_tampered:` and approvals were not exercised. Differences from 0.1.0 are marked "since 0.1.1" or "since 0.1.2". ## What this is, in plain words This page is the dictionary. The [quickstart](https://docs.kosa8.com/guides/rb-quickstart/) and the [developer guide](https://docs.kosa8.com/guides/rb-guide/) show how the pieces fit together; this page lists every piece, with the exact names to type and the exact things you get back. You do not need to read it top to bottom: search it for the method you are calling. A few words used throughout: the **daemon** is the kosa8 background service on your machine. The gem sends it requests over a **unix socket** (a file that acts like a network port, only reachable from your own computer). A **keyword argument** is a Ruby argument passed by name, as in `run(…, timeout: 30)`. A **hash** is Ruby's dictionary type; most methods return the daemon's answer as one. [`Kosa8::Client`The constructor and every method, with its endpoint.] [`Kosa8::Sandbox`The handle bound to one sandbox.] [`StructsExecResult`, `ExploreBranch`, `ExploreResult`.] [`Result shapes`The hashes methods return.] [`Transport`Endpoints, raw requests and frames.] [`Checkpoint storesCheckpointBook` and the two stores.] [`Errors`The exception classes and their fields.] ## The gem ``` gem "kosa8", "~> 0.1.2" # Gemfile (or: gem install kosa8 -v 0.1.2) require "kosa8" # loads version, errors, transport, checkpoints, client ``` | Name | Value | |---|---| | `Kosa8::VERSION` | `"0.1.2"` | | `Kosa8::MAX_CHECKPOINTS_PER_SANDBOX` | `10`. Checkpoints kept per sandbox; past it the oldest is evicted. | | `Kosa8::APPROVAL_HEADER` | `"X-Kosa8-Approval"` | | `Kosa8::ACTOR_HEADER` | `"X-Kosa8-Actor"` | | `Kosa8::AGENT_TOKEN_HEADER` | `"X-Kosa8-Agent-Token"` | | `Kosa8::BY_STATUS` | Frozen hash from HTTP status to error class: 400, 401, 403, 404, 409, 501. | | `Kosa8::TRANSIENT` | The regexp `explore` uses to decide a branch failure is worth retrying (see [explore]). | Ruby 2.6 or later (`required_ruby_version >= 2.6`). No dependencies: the gem uses only the standard library (`net/http`, `json`, `socket`, `openssl`, `base64`, `uri`, `fileutils`, `time`). MIT licensed. ## `Kosa8::Client` ``` Kosa8::Client.new( socket_path: nil, host: nil, token: nil, insecure: false, actor: "tool", agent_token: nil, headers: {}, timeout: nil, checkpoint_store: nil, env: ENV ) ``` All arguments are keyword arguments. Constructing a client sends nothing: it works out where to connect (and raises `Kosa8::Error` for a refused address or a bad `actor`) and sets up the checkpoint store. | Keyword | Default | Meaning | |---|---|---| | `socket_path:` | `~/.kosa8/run/kosa8.sock` | The daemon's unix socket. Used only when no host is set, here or in `KOSA8_HOST`. | | `host:` | `ENV["KOSA8_HOST"]` | A remote worker: `https://…`, `http://…`, `tcp://h:p`, `h:p`, or `unix:///path`. An empty string counts as not given. Rules under [`resolve_endpoint`]. | | `token:` | `ENV["KOSA8_TOKEN"]`, only when the host came from `KOSA8_HOST` | Sent as `Authorization: Bearer …`, to a TCP endpoint only, never over a unix socket. | | `insecure:` | `false` (or `KOSA8_INSECURE=1`) | Allows plain `http://` to a host that is not loopback. | | `actor:` | `"tool"` | `"human"`, `"agent"` or `"tool"`; sent as `X-Kosa8-Actor` on every request and recorded in the audit log. Anything else raises `Kosa8::Error` (`actor must be human, agent or tool, not "root"`). | | `agent_token:` | `ENV["KOSA8_AGENT_TOKEN"]` | Agent identity, sent as `X-Kosa8-Agent-Token` when non-empty. A token the daemon does not know is refused with `AuthError`, never treated as anonymous. | | `headers:` | `{}` | Extra headers on every request. Set after `Accept` and `X-Kosa8-Actor` (so they can replace those), before `Content-Type`, `X-Kosa8-Approval`, `X-Kosa8-Agent-Token` and `Authorization` (which win when the gem sets them). | | `timeout:` | `nil` | Seconds. Sets Net::HTTP's `read_timeout` to this and `open_timeout` to the smaller of this and 30, for every request. A per-call `timeout:` overrides it. With `nil`: streaming calls (`run`, `exec`, `exec_frames`, `attach`) wait for output as long as the command runs (since 0.1.1; 0.1.0 cut them off after 60 silent seconds), and every other call keeps Net::HTTP's defaults, 60 seconds to connect and 60 per read. See [Timeouts](https://docs.kosa8.com/guides/rb-guide/#timeouts). | | `checkpoint_store:` | `FileCheckpointStore.new` | Where checkpoint stacks are kept. Any object with `load` and `update { \|data\| … }`; see [Checkpoint stores]. | | `env:` | `ENV` | Where `KOSA8_HOST`, `KOSA8_TOKEN`, `KOSA8_INSECURE` and `KOSA8_AGENT_TOKEN` are read. Pass `{}` to ignore the environment. | | Reader | Type | Meaning | |---|---|---| | `transport` | `Kosa8::Transport` | The HTTP layer. `transport.endpoint.target` says where requests go, such as `unix:///Users/you/.kosa8/run/kosa8.sock`. | | `checkpoints` | `Kosa8::CheckpointBook` | The checkpoint stack logic over `checkpoint_store`. (On a `Sandbox` handle, `checkpoints` is a different method: the list.) | ### Conventions for every method - **`approval:`** Methods that list `approval: nil` send it, stripped, as the `X-Kosa8-Approval` header; a blank string sends nothing. Methods without it (`health`, `version`, the `list_*` and `sandbox_*` readers, `get_sandbox`, `list_checkpoints`) send none. - **Errors.** Any method that talks to the daemon can raise `Kosa8::ConnectionError` (daemon unreachable), `Kosa8::TimeoutError` (a connect or read timeout passed), and the [HTTP error class] matching the daemon's status. Only the extra errors are listed per method. - **Paths** below are shortened: `…` is `/kosa8/v1`. Every sandbox, container and snapshot id put into a path is percent-encoded with `Kosa8.enc`, so `c/1` is sent as `c%2F1`. - **Query flags** such as `wipe:` are sent as `?wipe=1` when `true` and left out when `false` or `nil`. - **Returned hashes** are `JSON.parse` output: string keys, Integer and Float numbers. The `list_*` methods and `sandbox_images` / `sandbox_containers` return `[]` when the daemon answers with an empty body or `null`. ### Daemon | Signature | Returns | HTTP | |---|---|---| | `health` | Hash: `status, version, commit, go, uptime_seconds, phase` | `GET …/health` | | `version` | `health["version"]`, such as `"0.9.36"` (`nil` if absent) | `GET …/health` | | `pull(ref, approval: nil)` | The daemon's answer. Pulls into the host's shared engine, not into a sandbox; for a sandbox use `pull_into_sandbox`. | `POST …/pull`, body `{"ref"}` | ### Sandboxes | Signature | Returns | HTTP | |---|---|---| | `list_sandboxes` | Array of [sandbox records] | `GET …/sandboxes` | | `get_sandbox(id_or_name)` | The record whose `id` matches, else whose `name` matches, else `nil`. Never raises for a missing sandbox. | `GET …/sandboxes`, filtered in the client | | `create_sandbox(name: nil, cpus: nil, mem_mb: nil, disk_gb: nil, mounts: nil, egress: nil, egress_allow: nil, capture: nil, labels: nil, broker: nil, approval: nil)` | Hash: `sandbox` (a record), `boot_ms`, and `broker` when one was asked for | `POST …/sandboxes` | | `start_sandbox(sandbox, approval: nil)` | Hash: `boot_ms`, `sandbox`, `warning`. Boots a stopped sandbox from its own disk; processes that were running are not brought back. | `POST …/sandboxes/{id}/up` | | `stop_sandbox(sandbox, approval: nil)` | Hash: `ok`, `sandbox` (state `stopped`), `warning`. The disk is kept; running processes end. | `POST …/sandboxes/{id}/down` | | `delete_sandbox(sandbox, wipe: false, keep_checkpoints: false, approval: nil)` | The daemon's answer (`{"ok"=>true}`) merged with `"checkpoints_dropped"` (an Integer) | `DELETE …/sandboxes/{id}` (`?wipe=1`). Then, unless `keep_checkpoints`, removes the sandbox's stack from the checkpoint store and sends `DELETE …/snapshots/{ckpt}` (with the same `wipe`) for each, ignoring their errors | | `pull_into_sandbox(sandbox, ref, approval: nil)` | Hash: `image` (the full reference, such as `"docker.io/library/alpine:latest"`), `ok` | `POST …/sandboxes/{id}/pull`, body `{"ref"}`. Fetched on the host and copied in, so it works with `egress: "deny"` | | `sandbox_images(sandbox)` | Array of image references (Strings) | `GET …/sandboxes/{id}/images` | | `sandbox_containers(sandbox)` | Array of [container hashes] | `GET …/sandboxes/{id}/containers` | | `sandbox_egress(sandbox)` | Hash: `policy`, `stats`, and more when they apply | `GET …/sandboxes/{id}/egress` | #### `create_sandbox` keywords | Keyword | Sent as | Notes | |---|---|---| | `name:` | `name` | Also the sandbox's id. Without it the daemon picks one. Left out of the body when `nil`, as are `cpus`, `mem_mb`, `disk_gb`, `mounts`, `labels` and `broker`. | | `cpus:`, `mem_mb:`, `disk_gb:` | same names | Integers. Daemon 0.9.36 defaults seen in tests: 2 CPUs, 32 GB disk. | | `egress:` | `egress` | A String passed as is: `"deny"`, `"any"`, `"fakenet"`, `"allow:host,host"`. Or a hash `{ allow: [...] }` (symbol or string key), turned into `"allow:a,b"` with each host stripped; an empty list raises `Kosa8::Error` (`an egress allow-list needs at least one host; use egress: "deny" for no network`). `nil` sends nothing and the daemon's default applies. | | `egress_allow:` | `egress` | An array of hosts; non-empty, it becomes `"allow:a,b"` and wins. Combined with an `egress:` other than `nil` or `"deny"` it raises `Kosa8::Error` before sending (`egress_allow already limits the network to the hosts it lists; do not also set egress`). An empty array is ignored. | | `mounts:` | `mounts` | Passed through unchanged. Not tested for this page; see [Native HTTP API](https://docs.kosa8.com/guides/native-api/) for what the daemon accepts. | | `capture:` | `capture` | Sent whenever it is not `nil` (so `false` is sent). | | `labels:` | `labels` | A hash, such as `{ "team" => "docs" }`; checked against the org policy and echoed in the record. | | `broker:` | `broker` | Credential broker configuration, passed through. See [Keep API keys out](https://docs.kosa8.com/guides/credentials/). | ### Running code | Signature | Returns | HTTP | |---|---|---| | `run(sandbox, image, args = [], env: nil, on_stdout: nil, on_stderr: nil, on_status: nil, timeout: nil, approval: nil)` | [`ExecResult`] | `POST …/sandboxes/{id}/run`, body `{"image","args","env"?}`; answer is NDJSON frames. Starts a fresh container, waits for it to exit. The image must already be in the sandbox | | `start_container(sandbox, image, args = [], approval: nil)` | The container id (String), such as `"k3-bea6352b"` | `POST …/sandboxes/{id}/start`, body `{"image","args"}`. Runs detached | | `exec(sandbox, container, args, on_stdout: nil, on_stderr: nil, timeout: nil, approval: nil)` | `ExecResult` | `POST …/sandboxes/{id}/exec`, body `{"container","args"}`; NDJSON frames. No `env:`: exec takes none | | `exec_frames(sandbox, container, args, timeout: nil, approval: nil) { \|frame\| … }` | `nil` | `POST …/sandboxes/{id}/exec`. Yields every frame, including the final one, with `data` already base64-decoded. Does *not* raise on an error frame; you see `{"done"=>true, "error"=>…}` | | `start_interactive(sandbox, image, args = [], env: nil, tty: false, approval: nil)` | The container id | `POST …/sandboxes/{id}/start-interactive`, body `{"image","args","env","tty"}` (`env` is `null` when not given). Stdin is held open for `write_stdin` | | `write_stdin(sandbox, container, data, approval: nil)` | `{"ok"=>true}` | `POST …/sandboxes/{id}/containers/{cid}/stdin`, `application/octet-stream`. A String is sent with a length; an IO (anything with `read`) or any other Enumerable of chunks is sent chunked. Once per container per client: a second call raises `Kosa8::Error` before sending (since 0.1.1) | | `attach(sandbox, container, on_stdout: nil, on_stderr: nil, timeout: nil, approval: nil)` | `ExecResult` when the container exits | `POST …/sandboxes/{id}/containers/{cid}/attach`; NDJSON frames | - **`args`** is converted with `to_a`. There is no shell unless you ask for one: `["sh", "-c", "echo $HOME"]`. - **`env:`** a hash (`{ "A" => "1" }`, sent as `["A=1"]`) or an array of `"K=V"` strings. - **Callbacks** (`on_stdout:`, `on_stderr:`, `on_status:`) are anything with `call`, such as a lambda. Each is called with one chunk, a binary String, as it arrives, on the calling thread. `on_status` gets daemon status lines (such as image pulling) that are not part of stdout or stderr. Frames with a stream other than `stderr` or `status` count as stdout. - **`timeout:`** seconds for this call, overriding the client's. It bounds the wait for *each read* from the daemon, not the whole run: a command that keeps printing can run longer. With no timeout anywhere, these calls wait as long as the command runs (since 0.1.1). A timeout stops the client waiting; it does not stop the command, which keeps running in the sandbox. - **Exit codes.** A non-zero exit is a result, not an exception: `exit_code` holds it. If the final frame carries no integer `exit`, `exit_code` is `0`. - **Extra errors** for `run`, `exec` and `attach`: `Kosa8::StreamError` when the stream ends with an error frame (image not in the sandbox, no such container, executable not found) or with no final frame. Its `stdout`/`stderr` hold the output so far. A line that is not valid NDJSON raises `Kosa8::ProtocolError` (since 0.1.1). An HTTP error before streaming starts raises the typed HTTP error. - **One body is the whole of stdin.** The daemon closes the process's stdin when the body of a `write_stdin` ends. Since 0.1.1 the client remembers each `[sandbox, container]` it has written to, and a second `write_stdin` for the same pair raises `Kosa8::Error` (`stdin of container … was already sent and is closed …`) instead of the daemon's silent `{"ok"=>true}` (0.1.0). The pair is marked before the request is sent, so if that first request fails (daemon unreachable, say), the same client will refuse a retry; use a new container. A different `Client` object does not know about the first write. To stream input, pass one IO or Enumerator. - **Your callbacks' exceptions come out unchanged** (since 0.1.1). An exception raised in `on_stdout`, `on_stderr`, `on_status` or an `exec_frames` block ends the call and is re-raised as the same object; 0.1.0 relabelled an `IOError` there as `ConnectionError`. ### Snapshots and fork | Signature | Returns | HTTP | |---|---|---| | `snapshot(sandbox, id: nil, encrypt: false, approval: nil)` | Hash: `snapshot` (a [snapshot record]), `snapshot_ms` | `POST …/sandboxes/{id}/snapshot`, body `{"id"?, "encrypt"?}` (`encrypt` only when true). Captures memory, processes and disks; the sandbox keeps running. A taken id raises `ConflictError` | | `list_snapshots` | Array of snapshot records (every sandbox's) | `GET …/snapshots` | | `restore_snapshot(snapshot_id, name: nil, label: nil, allow_tampered: false, approval: nil)` | Hash: `sandbox` (the new record, with `parent`), `restore_ms` | `POST …/snapshots/{id}/restore`, body `{"name"?, "label"?, "allow_tampered"?}`. Boots a new sandbox with processes resumed | | `delete_snapshot(snapshot_id, wipe: false, force: false, approval: nil)` | Hash: `encrypted`, `ok`, `removed` | `DELETE …/snapshots/{id}` (`?wipe=1`, `&force=1`) | | `fork(sandbox, n = 1, prefix: nil, label: nil, approval: nil)` | Hash: `count`, `fork_ms`, `forks` (array of sandbox records), `snapshot` (the id of the fork's snapshot, a String) | `POST …/sandboxes/{id}/fork`, body `{"n", "prefix"?, "label"?}`. `n` is sent as given. With `prefix: "p"` the copies are named `p1`, `p2`, … | In our tests, once every copy made by `fork` had been deleted, its `snapshot` had gone too (`delete_snapshot` on it answered `NotFoundError`). ### `explore` ``` explore(sandbox, variants, image: "alpine:latest", keep_all: false, keep: nil, timeout: 120, prefix: nil, pull: true, approval: nil) # => Kosa8::ExploreResult ``` Best-of-N, as `kosa8 explore` does it. In order: 1. `variants.to_a`; fewer than two raises `Kosa8::Error` (`give at least two variants; with one there is nothing to compare`) before anything is sent. 2. If `pull`: `pull_into_sandbox(sandbox, image)`. 3. `fork(sandbox, variants.length, prefix: …)`. The default prefix is `"#{sandbox}-explore-#{Time.now.to_i % 100_000}"`. If fewer branches come back than variants, raises `Kosa8::Error` (`forked N branches for M variants; see list_sandboxes for what was created`); the branches made are not deleted. 4. One Ruby `Thread` per branch runs `run(branch, image, ["sh", "-c", variant], timeout: timeout)`. A failure whose message matches `Kosa8::TRANSIENT` (connection refused, EOF, reset, broken pipe, "no such file or directory", stream ended, unreachable) is retried up to four attempts in all, sleeping 0.5, 1 and 1.5 seconds between them (since 0.1.1 there is no sleep after the last). A `TimeoutError` is never retried. Branch failures are recorded in the branch's `error`, not raised. 5. The winner is the successful branch (no error, exit 0) with the smallest `duration_ms`, or `""` if none succeeded. `keep:` names a branch to keep instead; a name that is not a branch raises `Kosa8::Error` (`keep X is not one of the branches (…); all of them were kept`) and nothing is deleted. 6. Unless `keep_all`, every branch other than the winner is deleted with `DELETE …/sandboxes/{branch}` (errors ignored, counted in `removed`). With no winner, that is every branch. The parent sandbox is never touched. One quirk worth knowing: `TRANSIENT` also matches "No such file or directory". Because variants run through `sh -c`, a command missing from the image is an ordinary exit 127 (in our test, `"python3 -V"` on Alpine came back as `exit=127 error=nil`, no retry). But in an image with no `sh` at all, the run fails with `… not found in $PATH: No such file or directory`, which the code treats as transient and retries four times (read from the code; not reproduced). ### Checkpoints Checkpoints are kept on the client side, over snapshots, in the same file and format the kosa8 CLI uses (`~/.kosa8/checkpoints.json`), so `kosa8 checkpoint ls` and `kosa8 rewind` see what the gem did and the other way round. | Signature | Returns | How | |---|---|---| | `checkpoint(sandbox, label: "", approval: nil)` | Hash: `"checkpoint"` (the stored record), `"evicted"` (ids, Array), `"snapshot"` (the `snapshot` answer) | Picks the next id `-ckpt-`; `POST …/sandboxes/{id}/snapshot` with it (on a 409 "already exists", steps to the next id and retries, up to 10 attempts, since 0.1.2); once that exists, `DELETE …/snapshots/{old}` for each evicted id past 10 (errors ignored); then writes the record to the store | | `list_checkpoints(sandbox)` | Array of checkpoint records, oldest first | The store only; sends nothing | | `rewind(sandbox, to: nil, approval: nil)` | Hash: `"sandbox"` (the restored record), `"checkpoint"` (the target record), `"dropped"` (ids), `"restore_ms"` | `DELETE …/sandboxes/{id}`, then `POST …/snapshots/{ckpt}/restore` with `{"name": sandbox}`, then drops every later checkpoint from the store and sends `DELETE …/snapshots/{later}` for each (errors ignored) | - **`to:`** a checkpoint id or label. Labels match case-insensitively and the newest match wins. Without it, the latest checkpoint. - **Extra errors:** `Kosa8::CheckpointError` for no checkpoints (`no checkpoints for "x" — take one with checkpoint or `kosa8 checkpoint x), a `to:` that matches nothing (`no checkpoint "nope" for sandbox "x"`), or, in `checkpoint`, an invalid sandbox name (empty, starting with a dot, containing `/` or `\`). - **A failed restore.** The sandbox is deleted first, because the restored machine takes its name. A refusal of that delete is raised as is and changes nothing. If the restore then fails, `rewind` raises `Kosa8::CheckpointError` with the message `restore failed and NAME no longer exists (it was deleted to free its name for the restore): … — the checkpoint is intact; recover with restore_snapshot("ID", name: "NAME")` (0.1.0 said "is now stopped", which was wrong), and `e.cause` is the daemon's error. At that point the sandbox is gone and the checkpoint's snapshot is intact; run the call the message names. - **Concurrent checkpoints.** The next id is read from the store before the snapshot is taken and written after, so two `checkpoint` calls on the same sandbox at the same moment (two threads, or the gem and the CLI) can pick the same id. Since 0.1.2, when the snapshot is refused with a `ConflictError` whose message contains "already exists", the call steps to the next number (`ckpt-n+1`) and tries again, up to 10 attempts in all; a 409 for any other reason, or the tenth refusal, is raised. The eviction list is the one computed before the first attempt. 0.1.0 raised on the first collision; 0.1.1 retried only when `list_snapshots` already showed the other snapshot, which often it did not. ### Helpers | Signature | Returns | |---|---| | `sandbox(sandbox_id)` | A [`Kosa8::Sandbox`] bound to that id. Sends nothing, and does not check the sandbox exists. | | `Kosa8::Client.egress_string(egress, allow)` | The `egress` string `create_sandbox` would send, or `nil`. Raises as described above. | | `Kosa8::Client.env_list(env)` | `nil`, or `["K=V", …]` from a hash, or `env.to_a`. | | `Kosa8::Client.utf8(bytes)` | A copy forced to UTF-8 with invalid bytes replaced (`scrub`). How `ExecResult` strings are made. | ## `Kosa8::Sandbox` ``` Kosa8::Sandbox.new(client, id) # usually: client.sandbox("name") sb.id # => "doc-rb-demo" sb.client # => the Kosa8::Client sb.inspect # => "#" ``` Each method calls the client method with the sandbox id filled in and passes the remaining keyword arguments through, so the keywords, results and errors are the client's. | Method | Calls | |---|---| | `info` | `get_sandbox(id)` | | `start(**kw)` | `start_sandbox(id, **kw)` | | `stop(**kw)` | `stop_sandbox(id, **kw)` | | `delete(**kw)` | `delete_sandbox(id, **kw)`: `wipe:`, `keep_checkpoints:`, `approval:` | | `pull(ref, **kw)` | `pull_into_sandbox(id, ref, **kw)` (not the client's `pull`) | | `images` | `sandbox_images(id)` | | `containers` | `sandbox_containers(id)` | | `egress` | `sandbox_egress(id)` | | `run(image, args = [], **kw)` | `run(id, image, args, **kw)` | | `start_container(image, args = [], **kw)` | `start_container(id, image, args, **kw)` | | `exec(container, args, **kw)` | `exec(id, container, args, **kw)` | | `snapshot(**kw)` | `snapshot(id, **kw)` | | `fork(n = 1, **kw)` | `fork(id, n, **kw)` | | `explore(variants, **kw)` | `explore(id, variants, **kw)` | | `exec_frames(container, args, **kw) { \|frame\| … }` | `exec_frames(id, container, args, **kw)` (since 0.1.1) | | `start_interactive(image, args = [], **kw)` | `start_interactive(id, image, args, **kw)` (since 0.1.1) | | `write_stdin(container, data, **kw)` | `write_stdin(id, container, data, **kw)` (since 0.1.1) | | `attach(container, **kw)` | `attach(id, container, **kw)` (since 0.1.1) | | `checkpoint(**kw)` | `checkpoint(id, **kw)` | | `checkpoints` | `list_checkpoints(id)` | | `rewind(**kw)` | `rewind(id, **kw)` | Not on the handle: `restore_snapshot`, `delete_snapshot`, `list_snapshots` (they are about snapshots, not this sandbox). Call them on `sb.client`. ## Structs All three are `Struct`s built with `keyword_init: true`, so they have readers and writers for each member, `to_h`, and value equality. ### `Kosa8::ExecResult` ``` ExecResult = Struct.new(:exit_code, :stdout, :stderr, keyword_init: true) r.exit_code # Integer r.stdout # String, UTF-8, invalid bytes replaced r.stderr # String, the same r.ok? # exit_code.zero? ``` ### `Kosa8::ExploreBranch` ``` ExploreBranch = Struct.new(:name, :variant, :exit, :output, :error, :duration_ms, keyword_init: true) b.name # the branch sandbox's id, such as "doc-rb-g2-e2" b.variant # the shell command it ran b.exit # Integer; -1 if it never ran b.output # stdout + stderr, trailing newline removed (chomp) b.error # the last error message, or nil b.duration_ms # Integer, including retries ``` ### `Kosa8::ExploreResult` ``` ExploreResult = Struct.new(:parent, :branches, :winner, :removed, keyword_init: true) x.parent # the sandbox explored x.branches # Array of ExploreBranch, in variant order x.winner # the kept branch's name, or "" when none succeeded x.removed # Integer: branches deleted ``` A real one, from three variants (`"sleep 2; echo slow"`, `"echo fast"`, `"echo broken; exit 1"`): ``` "doc-rb-g2-e2" 2 # # # ``` ## Result shapes Other methods return the daemon's JSON as plain hashes and arrays. The keys you will meet most, as kosa8 0.9.36 sent them in our tests: | Shape | Keys | |---|---| | sandbox record | `id, name, created, state` (`running`, `stopped`, …), `cpus, mem_mb, disk_gb, egress, ip`, and when set `parent, labels, mounts, capture, broker` | | snapshot record | `id, sandbox, created, machine_id, mac, cpus, mem_mb, disk_gb, cmdline, size_bytes, state_path, disk_path, data_path, egress`, and optional fields such as `sealed, labels, encryption` | | checkpoint record | `id, sandbox, label, created` (UTC with nine fraction digits, such as `2026-11-03T06:46:25.129669000Z`). Records written by the Go tools may also carry `auto`. | | container | `id, image, status, ip`, and `exit` when known | | egress status | `policy`, and `stats`: `passed, denied, dropped, dns_answered, dns_refused` | | exec frame | `{"stream"=>"stdout"\|"stderr"\|"status", "data"=>bytes}`, then a final `{"done"=>true, "ok"=>true, "exit"=>4}` or `{"done"=>true, "error"=>"…"}` | A real sandbox record, from `create_sandbox(name: "doc-rb-guide", cpus: 2, mem_mb: 1024, egress_allow: ["example.com"], labels: { "team" => "docs" })`: ``` {"id"=>"doc-rb-guide", "name"=>"doc-rb-guide", "created"=>"2026-11-03T01:39:22.091122-05:00", "state"=>"running", "cpus"=>2, "mem_mb"=>1024, "disk_gb"=>32, "egress"=>"allow:example.com", "ip"=>"192.168.127.2", "labels"=>{"team"=>"docs"}} ``` ## Transport and endpoints ### `Kosa8::Transport` ``` Kosa8::Transport.new(socket_path: nil, host: nil, token: nil, insecure: false, actor: "tool", agent_token: nil, headers: {}, timeout: nil, env: ENV) ``` The same connection keywords as `Client.new`, without `checkpoint_store:`. It opens a new connection for every request (no keep-alive), so one transport can be used from several threads at once. | Member | Meaning | |---|---| | `endpoint` | The resolved `Kosa8::Endpoint`. | | `raw(method, path, query: nil, body: nil, raw_body: nil, approval: nil, timeout: nil, stream: false) { \|resp\| … }` | `stream: true` (what `frames` passes) sets no read timeout when none is given. Sends one request and yields the `Net::HTTPResponse` with its body unread; returns the block's value. `body` is sent as JSON; `raw_body` as `application/octet-stream` (a String with a length, an IO or Enumerable chunked). For a TCP endpoint the endpoint's `base_path` is put before `path`. Raises `TimeoutError` for `Net::ReadTimeout`/`Net::OpenTimeout`, and `ConnectionError` for any `SystemCallError`, `IOError`, `SocketError` or `OpenSSL::SSL::SSLError`, adding the "start it with `kosa8 daemon start`" hint for `ENOENT` and `ECONNREFUSED`. | | `json(method, path, **kw)` | `raw`, then the body parsed as JSON (`nil` for an empty body, the text itself if it is not JSON). A status of 400 or more raises `Kosa8.error_from_response(…)`. | | `frames(method, path, **kw) { \|frame\| … }` | `raw` with `stream: true`, then yields one frame per NDJSON line, with `data` base64-decoded. An HTTP error before streaming raises the typed error; a line that is not JSON (or bad base64) raises `ProtocolError`. Returns `nil`. | An exception raised by the block given to `frames` (and so by your callbacks) passes through `raw`'s network rescue unchanged: a `Kosa8::Error` is re-raised as is, and any other `StandardError` is carried past the rescue inside the internal `Kosa8::CallbackFailure` (an `Exception` subclass) and re-raised as the original object. You never see `CallbackFailure` yourself. In 0.1.0 such an `IOError` became a `ConnectionError`, and a malformed line raised `JSON::ParserError`. ### `Kosa8.resolve_endpoint` ``` Kosa8.resolve_endpoint(host: nil, socket_path: nil, token: nil, insecure: false, env: ENV) # => Kosa8::Endpoint ``` Where to connect, by the kosa8 CLI's rules: 1. The address is `host` if it is non-empty, else `env["KOSA8_HOST"]`, stripped of spaces and trailing slashes. Empty: the unix socket at `socket_path` or `Kosa8.default_socket_path`. 2. The scheme is lower-cased. `unix:///path` is a socket (and wins over `socket_path`). `tcp://` is read as `http://`. No scheme (`host:port`) is read as `http://`. Any other scheme raises `Kosa8::Error` (`KOSA8_HOST scheme "ftp" is not supported; use http://, https://, tcp:// or unix://`). An address with no host name raises `Kosa8::Error` (`KOSA8_HOST "…" names no host`). 3. Plain `http` to a host other than `localhost`, `::1` or `127.*` raises `Kosa8::Error` (`refusing to talk to 10.0.0.5:7440 over plain http: …`) unless `insecure` or `KOSA8_INSECURE=1`. 4. The token is `token` if non-empty; otherwise `env["KOSA8_TOKEN"]`, but only when the address came from `KOSA8_HOST`, never with an explicit `host:`. A path in the URL is kept as a prefix for every request. `https` uses Net::HTTP's default certificate verification. `Kosa8::Endpoint` is a `Struct` (`keyword_init`) with `kind` (`:unix` or `:tcp`), `socket_path`, `scheme`, `host`, `port`, `base_path`, `token`, and `target`: `"unix:///path"` or `"scheme://host:port"` (the port always shown, as in `https://w.example.com:443`). ### Other module functions and classes | Name | Meaning | |---|---| | `Kosa8.default_socket_path` | `File.join(Dir.home, ".kosa8", "run", "kosa8.sock")` | | `Kosa8.loopback?(host)` | True for `localhost`, `::1` (with or without brackets) and `127.*`. | | `Kosa8.enc(s)` | Percent-encodes one path segment; a space becomes `%20` and `/` becomes `%2F`. | | `Kosa8.decode_frame(hash)` | Replaces a non-empty String `"data"` with its base64 decoding, in place, and returns the hash. | | `Kosa8.validate_sandbox_name(name)` | Returns `name`, or raises `CheckpointError` (`invalid sandbox name "../etc"`). | | `Kosa8.parse_time(s)` | `Time.iso8601(s)`, or `Time.at(0)` if it cannot be parsed. Used to order checkpoints. | | `Kosa8::UnixHTTP` | A `Net::HTTP` subclass that connects to `socket_path` instead of a TCP port. Requests go to host `kosa8`. | | `Kosa8::ChunkReader` | An IO-like over an Enumerable of string chunks (`read(len = nil, outbuf = nil)`), used for chunked `write_stdin` bodies. | ## Checkpoint stores A store holds `{ "sandbox-name" => [checkpoint record, …] }`. It needs two methods: `load` (returns the data) and `update { |data| … }` (runs the block on the data, saves what it leaves, and returns the block's value). | Class | Behaviour | |---|---| | `Kosa8::FileCheckpointStore.new(path = nil, lock_timeout: 10)` | `path` and `lock_timeout` (readers). `path` defaults to `~/.kosa8/checkpoints.json`, the CLI's own file. `load` returns `{}` when the file is missing or not valid JSON (other errors, such as `Errno::EACCES`, are raised as they are). `update` takes a `Mutex` in the process, creates the directory (mode 0700) if needed, takes an exclusive `flock` on `checkpoints.json.lock` (the CLI's lock, so the two never interleave), reloads, runs the block, writes pretty JSON to `checkpoints.json.tmp` (mode 0600) and renames it over the file. The lock is tried without blocking every 50 ms for up to `lock_timeout` seconds (since 0.1.1; 0.1.0 waited for ever), then `CheckpointError` is raised: `timed out after 10s waiting for …/checkpoints.json.lock; another kosa8 process is holding it`. | | `Kosa8::MemoryCheckpointStore.new` | Keeps stacks in memory, under a `Mutex`, deep-copied with `Marshal` on every `load` and `update`. Nothing is shared with the CLI or other processes, and nothing survives the process. Use it in tests. | | `Kosa8::CheckpointBook.new(store)` | The stack logic, as `client.checkpoints`. See below. | | `CheckpointBook` method | Returns | |---|---| | `next_id(sandbox)` | `[id, evict]`: the next `"-ckpt-"`, with `n` one past the larger of the stack's length and the highest number used, and the oldest ids to evict to stay at 10. Validates the name. | | `record(id, sandbox, label, evicted = [])` | The new record `{"id", "sandbox", "label", "created"}`, appended after removing `evicted`. `created` is `Time.now.utc.iso8601(9)`. No `auto` key is written. Validates the name. | | `list(sandbox)` | Records sorted by `created`, oldest first (ties keep file order). | | `latest(sandbox)` | The newest record, or raises `CheckpointError`. | | `resolve(sandbox, ref)` | The newest record whose `id` equals `ref` or whose label equals it ignoring case, or raises `CheckpointError`. | | `pop(sandbox, id)` | Keeps the stack up to and including `id`; returns the ids removed after it. | | `forget(sandbox)` | Removes the sandbox's stack; returns its ids. | | `CheckpointBook.seq(id, sandbox)` | The number in `"-ckpt-"`, or 0. | ## Errors ``` StandardError └─ Kosa8::Error ├─ Kosa8::ConnectionError ├─ Kosa8::TimeoutError ├─ Kosa8::StreamError #stdout, #stderr ├─ Kosa8::CheckpointError ├─ Kosa8::ProtocolError a frame that is not NDJSON (since 0.1.1) └─ Kosa8::ApiError any HTTP error status without a closer class (500, 503, …) ├─ Kosa8::BadRequestError 400 ├─ Kosa8::AuthError 401 ├─ Kosa8::PolicyError 403 │ └─ Kosa8::ApprovalRequiredError 403 with an approval refusal; #approval ├─ Kosa8::NotFoundError 404 ├─ Kosa8::ConflictError 409 └─ Kosa8::UnsupportedError 501 ``` | Reader | On | Meaning | |---|---|---| | `message` | all | For a daemon error: `" ()"`, as the CLI prints it. | | `status` | `ApiError` and below | The HTTP status (Integer); `nil` elsewhere. | | `daemon_message` | daemon errors, stream error frames | The daemon's own text. | | `remediation` | daemon errors | The daemon's advice, when it gave one. | | `http_method`, `path` | daemon, connection, timeout and protocol errors | The request, such as `"GET"` and `"/kosa8/v1/health"`. Named `http_method` since 0.1.1; in 0.1.0 it was `method`, which hid Ruby's `Object#method`. The constructor keyword is still `method:`. | | `body` | daemon errors | The parsed response body. | | `stdout`, `stderr` | `StreamError` | Output produced before the failure (UTF-8 Strings). | | `approval` | `ApprovalRequiredError` | The daemon's refusal hash: `why` and its other fields. | ``` Kosa8::Error.new(message = nil, status: nil, daemon_message: nil, remediation: nil, method: nil, path: nil, body: nil) Kosa8::ApprovalRequiredError.new(message = nil, approval: {}, **kw) Kosa8::StreamError.new(message = nil, stdout: "", stderr: "", **kw) Kosa8.error_from_response(status, body, method:, path:) # => the typed error (not raised) ``` `error_from_response` builds the error the client raises for an HTTP status of 400 or more. `daemon_message` is the body's `error` if it is a non-empty String, else its `message`; for a text body, the stripped text. `remediation` is the body's non-empty `remediation`. The message is `" ()"`, the daemon message alone, or `"request failed with HTTP "`. A 403 whose body has an `approval` hash with a String `why` becomes `ApprovalRequiredError`; other statuses use `Kosa8::BY_STATUS`, falling back to `ApiError`. `Kosa8::TimeoutError` is the gem's own class, not Ruby's `Timeout::Error`, and does not inherit from it. Real messages and how to handle each class: [Ruby errors and troubleshooting](https://docs.kosa8.com/guides/rb-errors/). --- Source: https://docs.kosa8.com/guides/rb-api/ # Java quickstart One `Main.java`, five minutes. You will connect to kosa8 from Java, start a sealed sandbox with no network, run a command in it, save it, break it, put it back, and delete it. **How this page was tested** The program on this page was run against a real kosa8 daemon 0.9.36 on an Apple silicon Mac (macOS 26.0.1) with `com.kosa8:kosa8:0.1.2`, built from the `v0.1.2` tag (commit 14e5fc6) with `mvn install`, because Maven Central was still answering 404 for 0.1.2 while it was being published, into an empty local repository by Apache Maven 3.10.0. It was compiled with `--release 17` and run on OpenJDK 21.0.12.1. The same program, unchanged, was also run earlier on 0.1.1 downloaded from Maven Central into an empty local repository; it needed no extra Maven flags. The output shown is what that run printed. Timings are from a machine that was busy with other sandboxes at the time, and will differ on yours. The Gradle lines were *not* run: Gradle was not installed on the test machine. ## What this is, in plain words A **sandbox** is a small, separate computer that runs inside your computer. It has its own operating system, its own files and its own network settings. Code running inside it cannot see your files, your passwords or your network unless you allow it. If the code does something bad, the damage stays inside the sandbox, and you can throw the sandbox away. That matters when code comes from somewhere you do not fully trust: an AI agent writing and running its own commands, a user's script on your server, a package you have not reviewed. kosa8 runs that code in a sandbox on your own machine, not in someone else's cloud. The **Java SDK** (`com.kosa8:kosa8`) lets a Java program do all of this: create sandboxes, run commands in them, read the output, save a sandbox's exact state and go back to it later. It talks to the kosa8 background service (the daemon) that runs on your machine. It has no dependencies at all: one 75 kB jar that uses only the JDK. ## What you need | Part | Requirement | |---|---| | kosa8 | Installed, with its daemon running. See [Install](https://docs.kosa8.com/guides/install/). `kosa8 version` shows both parts. | | Machine | macOS on Apple silicon, x86-64 Linux with KVM, or Windows 11 on x86-64 (run your program inside WSL2). Sandboxes are real virtual machines. | | Java | 17 or later. The SDK reaches the daemon over a unix socket with `java.net.UnixDomainSocketAddress`, which arrived in Java 16, and it uses records and pattern matching, so 17 is the floor. | | Build tool | Maven or Gradle, or none: the jar can go straight on `java -cp`. | | Disk | Room for the guest image the daemon downloads on first use (about 1.5 GB), plus sandboxes and checkpoints. | ``` $ kosa8 version kosa8 client 0.9.36 (f6124c0, darwin/arm64) kosa8 daemon 0.9.36 (up 4186s) ``` If the second line says the daemon is not running, start it with `kosa8 daemon start` (the Mac app starts it for you). ## Add the dependency The coordinates are group `com.kosa8`, artifact `kosa8`, version `0.1.2`, on Maven Central. The package is `com.kosa8`. ### Maven ``` 17 com.kosa8 kosa8 0.1.2 ``` ### Gradle ``` // build.gradle.kts dependencies { implementation("com.kosa8:kosa8:0.1.2") } // build.gradle (Groovy) dependencies { implementation 'com.kosa8:kosa8:0.1.2' } ``` ### No build tool ``` $ curl -sO https://repo1.maven.org/maven2/com/kosa8/kosa8/0.1.2/kosa8-0.1.2.jar $ java -cp kosa8-0.1.2.jar Main.java # Java 11+ runs a single source file directly ``` If Maven reports `NOT allowed from central` for the jar, your local repository holds an old copy of Central's prefix list; see [Java errors and troubleshooting](https://docs.kosa8.com/guides/java-errors/#maven-prefixes). ## The walkthrough The steps below are parts of one file, `src/main/java/Main.java`. The whole file, and what it printed, is at the end. The work after the sandbox is created sits inside `try` … `finally`, so the clean-up in step 6 always runs, even when a later step throws. ### Connect ``` // 1. Connect. With no settings the client uses ~/.kosa8/run/kosa8.sock. Kosa8Client k = Kosa8Client.builder().build(); System.out.println("kosa8 " + k.version() + " at " + k.endpoint().target()); ``` ``` kosa8 0.9.36 at unix:///Users/dhruvit/.kosa8/run/kosa8.sock ``` `build()` only works out where to connect; nothing is sent until the first call. `version()` asks the daemon for its version, which proves the connection works. If the daemon is not running, this is where you find out: it throws a `Kosa8Exception.ConnectionException` whose message says so, and says how to start it. Every exception the SDK throws is unchecked, so there is no `throws` clause to add. ### Create a sandbox with no network ``` // 2. Create a sandbox: a microVM with its own kernel and no network at all. Map created = k.createSandbox(new Options.Create().name("doc-java-demo").egress("deny")); Sandbox sb = k.sandbox("doc-java-demo"); try { @SuppressWarnings("unchecked") Map info = (Map) created.get("sandbox"); System.out.printf("sandbox %s: %s, egress %s, booted in %sms%n", info.get("name"), info.get("state"), info.get("egress"), created.get("boot_ms")); ``` ``` sandbox doc-java-demo: running, egress deny, booted in 13096ms ``` `egress("deny")` closes the network completely. The rule is enforced on your computer, outside the sandbox, so code inside cannot turn it off. Options are small builder objects: every setter returns the same object, and anything you do not set is left to the daemon's default. `k.sandbox(name)` returns a `Sandbox` handle with the name filled in, so later calls are shorter. The answer is the daemon's JSON decoded into plain Java maps and lists. Booting took 13 seconds here because the machine was busy; on an idle Mac it is usually a second or two. ### Pull an image and run a command ``` // 3. Pull an image into the sandbox (fetched on the host), then run a command to completion. sb.pull("alpine:latest"); Results.Exec res = sb.run("alpine:latest", List.of("sh", "-c", "echo kernel $(uname -r); wget -q -T 3 -O- http://example.com || echo no network; exit 3")); System.out.print("exit " + res.exitCode() + "\n" + res.stdout()); ``` ``` exit 3 kernel 6.18.35 no network ``` Code in a sandbox runs in containers on the sandbox's own engine, so it needs an image. `pull` fetches it on the host and copies it in, which works even though the sandbox has no network. `run` starts a fresh container, waits for it to finish and returns its output. The kernel is the sandbox's own, and the download failed, as it should. A command that exits with a non-zero code is a *result*, not an exception: `run` returns normally and `res.exitCode()` is 3 (`res.ok()` is false). It throws only when the command could not run at all. ### Keep a container running ``` // 4. Keep a container running, and write a file in it. String cid = sb.startContainer("alpine:latest", List.of("sh", "-c", "while :; do sleep 3600; done")); System.out.println("wrote /note: " + sh(sb, cid, "echo v1 > /note; cat /note")); // ...and, as a method of the class: static String sh(Sandbox sb, String cid, String cmd) { Results.Exec r = sb.exec(cid, List.of("sh", "-c", cmd), null); return (r.stdout() + r.stderr()).strip(); } ``` ``` wrote /note: v1 ``` `run` starts from a clean image every time. To keep files between commands, start a container that stays up and `exec` commands in it. The last argument of `exec` is an optional `Options.Run`; `null` means none. ### Checkpoint, break something, rewind ``` // 5. Checkpoint, break something, rewind. Results.CheckpointSaved cp = sb.checkpoint("before the risky bit"); System.out.printf("checkpoint %s (%s) in %sms%n", cp.checkpoint().id(), cp.checkpoint().label(), cp.snapshot().get("snapshot_ms")); sh(sb, cid, "rm /note"); System.out.println("after rm: " + sh(sb, cid, "cat /note")); Results.Rewind rw = sb.rewind(null); System.out.printf("rewound to %s in %sms%n", rw.checkpoint().id(), rw.restoreMs()); System.out.println("after rewind: /note = " + sh(sb, cid, "cat /note")); ``` ``` checkpoint doc-java-demo-ckpt-1 (before the risky bit) in 548ms after rm: cat: can't open '/note': No such file or directory rewound to doc-java-demo-ckpt-1 in 480ms after rewind: /note = v1 ``` A checkpoint saves the whole running sandbox: memory, running programs and disks. `rewind(null)` goes back to the latest one; pass a label or id to pick another. The sandbox returns under the same name with the same container still running, so the same container id works and the deleted file is back. ### Delete ``` } finally { // 6. Always clean up, even if a step above threw. System.out.println("deleted: " + sb.delete()); } ``` ``` deleted: {ok=true, checkpoints_dropped=1} ``` `delete()` removes the sandbox and, by default, its checkpoints and their snapshots too. `checkpoints_dropped` says how many went. `Kosa8Client` and `Sandbox` are not `AutoCloseable`: there is no connection to close (each call opens and closes its own), and deleting a sandbox is a decision, so it is written out in `finally`. ## The whole file ``` import com.kosa8.Kosa8Client; import com.kosa8.Options; import com.kosa8.Results; import com.kosa8.Sandbox; import java.util.List; import java.util.Map; public class Main { public static void main(String[] args) { // 1. Connect. With no settings the client uses ~/.kosa8/run/kosa8.sock. Kosa8Client k = Kosa8Client.builder().build(); System.out.println("kosa8 " + k.version() + " at " + k.endpoint().target()); // 2. Create a sandbox: a microVM with its own kernel and no network at all. Map created = k.createSandbox(new Options.Create().name("doc-java-demo").egress("deny")); Sandbox sb = k.sandbox("doc-java-demo"); try { @SuppressWarnings("unchecked") Map info = (Map) created.get("sandbox"); System.out.printf("sandbox %s: %s, egress %s, booted in %sms%n", info.get("name"), info.get("state"), info.get("egress"), created.get("boot_ms")); // 3. Pull an image into the sandbox (fetched on the host), then run a command to completion. sb.pull("alpine:latest"); Results.Exec res = sb.run("alpine:latest", List.of("sh", "-c", "echo kernel $(uname -r); wget -q -T 3 -O- http://example.com || echo no network; exit 3")); System.out.print("exit " + res.exitCode() + "\n" + res.stdout()); // 4. Keep a container running, and write a file in it. String cid = sb.startContainer("alpine:latest", List.of("sh", "-c", "while :; do sleep 3600; done")); System.out.println("wrote /note: " + sh(sb, cid, "echo v1 > /note; cat /note")); // 5. Checkpoint, break something, rewind. Results.CheckpointSaved cp = sb.checkpoint("before the risky bit"); System.out.printf("checkpoint %s (%s) in %sms%n", cp.checkpoint().id(), cp.checkpoint().label(), cp.snapshot().get("snapshot_ms")); sh(sb, cid, "rm /note"); System.out.println("after rm: " + sh(sb, cid, "cat /note")); Results.Rewind rw = sb.rewind(null); System.out.printf("rewound to %s in %sms%n", rw.checkpoint().id(), rw.restoreMs()); System.out.println("after rewind: /note = " + sh(sb, cid, "cat /note")); } finally { // 6. Always clean up, even if a step above threw. System.out.println("deleted: " + sb.delete()); } } // Run a shell command in the running container; return its output, trimmed. static String sh(Sandbox sb, String cid, String cmd) { Results.Exec r = sb.exec(cid, List.of("sh", "-c", cmd), null); return (r.stdout() + r.stderr()).strip(); } } ``` With the `exec-maven-plugin` (`Main`) in the `pom.xml`: ``` $ mvn -q compile exec:java kosa8 0.9.36 at unix:///Users/dhruvit/.kosa8/run/kosa8.sock sandbox doc-java-demo: running, egress deny, booted in 13096ms exit 3 kernel 6.18.35 no network wrote /note: v1 checkpoint doc-java-demo-ckpt-1 (before the risky bit) in 548ms after rm: cat: can't open '/note': No such file or directory rewound to doc-java-demo-ckpt-1 in 480ms after rewind: /note = v1 deleted: {ok=true, checkpoints_dropped=1} ``` ## Look at it from the command line The SDK and the CLI drive the same daemon and share the same checkpoint list (`~/.kosa8/checkpoints.json`), so you can watch what your program does, or finish a job by hand: ``` $ kosa8 sandbox ls $ kosa8 checkpoint ls doc-java-demo $ kosa8 audit ls -n 10 # your program's calls, recorded as actor "tool" ``` **If the rewind fails on a Mac** Restoring a saved machine needs the screen to be unlocked. With the screen locked the checkpoint succeeds and the rewind fails. By then the rewind has already removed the sandbox; the checkpoint's snapshot is intact, and the `CheckpointException` message ends with the `restoreSnapshot(…)` call that brings it back. See [Java errors and troubleshooting](https://docs.kosa8.com/guides/java-errors/#rewind-failed). **Next** [Java developer guide](https://docs.kosa8.com/guides/java-guide/): every capability in detail. [Errors and troubleshooting](https://docs.kosa8.com/guides/java-errors/) and the [API reference](https://docs.kosa8.com/guides/java-api/) list everything else. --- Source: https://docs.kosa8.com/guides/java-quickstart/ # Java developer guide Each section starts with what the feature is for, in plain words, then goes into exactly what the SDK does: which HTTP call it makes, what comes back, what can go wrong. The [quickstart](https://docs.kosa8.com/guides/java-quickstart/) is the five-minute version; the [API reference](https://docs.kosa8.com/guides/java-api/) lists every signature. **How this page was tested** This page describes SDK **0.1.2** (`com.kosa8:kosa8:0.1.2` on Maven Central; see the [quickstart](https://docs.kosa8.com/guides/java-quickstart/)). The capture and mount examples, the only code 0.1.2 changed, were run on 0.1.2 built from the `v0.1.2` tag (commit 14e5fc6) with `mvn install`, because Maven Central was still answering 404 for 0.1.2 while it was being published. Every block of output on this page was printed by a real program run against kosa8 daemon 0.9.36 on an Apple silicon Mac (macOS 26.0.1) on OpenJDK 21.0.12.1. Everything that changed in 0.1.1 (mounts, capture, the stdin and exec-env checks, the `Sandbox` handle's new methods, concurrent checkpoints, the lock, the redacted `Endpoint`) was run on 0.1.1, built from its release tag just before it was published. The quickstart was re-run on the 0.1.1 jar from Maven Central. Output from code paths that 0.1.1 did not change was first recorded on 0.1.0 from Maven Central. The agent loop, the shared-checkpoint example and the run/exec/stdin/frames examples were then re-run on 0.1.1, and they behaved the same apart from timings and container ids. Long JSON answers are shortened where marked with `…`; nothing else was edited. Timings come from a machine that was busy with other sandboxes. Where a statement comes from reading the SDK's source rather than from a run (signed approvals, encrypted snapshots, checkpoint eviction at 10, remote workers over https), the text says so. ## What this is, in plain words kosa8 gives a program a **sandbox**: a small virtual computer, with its own operating system and its own disk, that runs on your machine. You decide whether it may use the network, and which websites. Code inside it cannot reach your files or your passwords. You can save it at any moment and go back to that moment later, or make copies of it and try different things in each. The Java SDK is a library that lets a Java program drive all of that. It is a thin layer over the daemon's HTTP API: each method sends one request (a few send several) and gives back the answer. It has no dependencies, so adding it to a project adds one jar and nothing else. ## The pieces | Class | In plain words | In detail | |---|---|---| | `Kosa8Client` | Your connection to kosa8. | Built with `Kosa8Client.builder()…build()`. Every operation is a method on it, taking the sandbox name or id as the first argument. Final class, immutable after `build()`. | | `Sandbox` | A shortcut for one sandbox. | `client.sandbox("name")`. The same methods without the first argument. It holds only the client and the name; creating one sends nothing and does not check that the sandbox exists. | | `Options.*` | The settings for one call. | Small builders: `new Options.Create().name("x").egress("deny")`. Each setter returns the same object. Fields you do not set are not sent, so the daemon's defaults apply. `null` wherever an options argument is accepted means "no options". | | `Results.*` | What came back. | Java records: `Exec`, `Fork`, `Explore`, `ExploreBranch`, `CheckpointSaved`, `Rewind`. Calls with no record of their own return the daemon's JSON as `Map`. | | `Checkpoint` | One saved restore point. | Record `(id, sandbox, label, auto, created)`. | | `Kosa8Exception` | Something went wrong. | Unchecked (extends `RuntimeException`), with nested subclasses per cause. See [Errors and troubleshooting](https://docs.kosa8.com/guides/java-errors/). | **JSON in Java.** The SDK carries its own small JSON codec (`com.kosa8.Json`, public). Objects decode to `Map` (a `LinkedHashMap`, so key order is the daemon's), arrays to `List`, whole numbers to `Long`, other numbers to `Double`, and `true`/`false` to `Boolean`. So `created.get("boot_ms")` is a `Long`, and nested objects need a cast: ``` @SuppressWarnings("unchecked") Map info = (Map) created.get("sandbox"); // or, without the warning: if (created.get("sandbox") instanceof Map m) System.out.println(m.get("state")); ``` ## Connecting **In plain words.** Most of the time kosa8 runs on the same machine as your program, and `Kosa8Client.builder().build()` is all you need. If kosa8 runs on another machine (a "worker"), you give the client its address and a password-like token. **In detail.** Java's built-in `java.net.http.HttpClient` cannot talk over a unix socket, so the SDK writes HTTP/1.1 itself: over a `UnixDomainSocketAddress` for the local daemon, or a plain or TLS `Socket` for a worker. Each request opens a new connection and sends `Connection: close`; there is no pool and nothing to shut down. `build()` resolves where to connect and checks the settings; it sends nothing. ### Where requests go | You set | The client talks to | |---|---| | nothing | The unix socket `~/.kosa8/run/kosa8.sock` (`Endpoint.defaultSocketPath()`, from the `user.home` system property). | | `socketPath("/path/kosa8.sock")` | That socket. Ignored if a host is set here or in `KOSA8_HOST`. | | `host(…)` or `KOSA8_HOST` | A worker. `host(…)` wins over the variable. Forms: `https://h[:port][/base]`, `http://h[:port]`, `tcp://h:port` (same as `http://`), `unix:///path` (a socket), or a bare `h:port` (taken as `http://`). Trailing slashes are dropped; a path after the host becomes a prefix on every request. | ``` System.out.println(Kosa8Client.builder().host("https://worker.example.com/k8/").token("t").build().endpoint()); System.out.println(Kosa8Client.builder().host("tcp://127.0.0.1:7070").build().endpoint().target()); ``` ``` Endpoint[https://worker.example.com:443/k8, token=***] http://127.0.0.1:7070 ``` `Endpoint.toString()` never prints the token, only `token=***` when one is set (0.1.0 printed it). The token itself is still available as `endpoint().token()`. ### Settings and environment variables | Builder method | Environment variable | Default | What it does | |---|---|---|---| | `socketPath(String)` | none | `~/.kosa8/run/kosa8.sock` | The local daemon's socket. | | `host(String)` | `KOSA8_HOST` | none (local socket) | A remote worker, forms above. | | `token(String)` | `KOSA8_TOKEN` | none | Sent as `Authorization: Bearer …`, only to a network endpoint, never over a unix socket. The variable is read *only when the host also came from `KOSA8_HOST`*: if you call `host(…)`, call `token(…)` too. | | `insecure(boolean)` | `KOSA8_INSECURE=1` | false | Allows plain `http://` to a host that is not loopback (`localhost`, `::1`, `127.*`). Without it, `build()` refuses, because the token would cross the network unencrypted. | | `actor(String)` | none | `"tool"` | `human`, `agent` or `tool`, sent as `X-Kosa8-Actor` and recorded in the audit log. Anything else makes `build()` throw. | | `agentToken(String)` | `KOSA8_AGENT_TOKEN` | none | Sent as `X-Kosa8-Agent-Token`, for [agent identity](https://docs.kosa8.com/guides/agent-identity/). | | `headers(Map)` | none | none | Extra header lines on every request, written after `X-Kosa8-Actor` and before the approval, agent-token and authorization headers. They are added, not substituted: repeating a header the SDK sets sends it twice. | | `timeout(Duration)` | none | none | A time limit for *every* request; see [Timeouts]. | | `checkpoints(CheckpointStore)` | none | the CLI's `~/.kosa8/checkpoints.json` | Where checkpoint stacks are kept; see [Checkpoints]. | | `getenv(Function)` | none | `System::getenv` | Where the variables above are read from. For tests. | ``` // The variables can come from anywhere; here, a map. Map env = Map.of("KOSA8_HOST", "worker.internal:9000", "KOSA8_INSECURE", "1", "KOSA8_TOKEN", "sekret"); System.out.println(Kosa8Client.builder().getenv(env::get).build().endpoint()); ``` ``` Endpoint[http://worker.internal:9000, token=***] ``` A network connection is given 30 seconds to connect (fixed in the SDK); https uses the JVM's default `SSLSocketFactory`, so the JVM's trust store decides which certificates are accepted. Remote workers were not exercised for this page; the forms above were checked with `endpoint()` only. ### Is the daemon there? ``` System.out.println("health " + k.health()); ``` ``` health {commit=f6124c0, go=go1.26.9, phase=0-foundations, status=ok, uptime_seconds=4328, version=0.9.36} ``` `version()` is `health().get("version")` as a string. ## Sandboxes **In plain words.** Creating a sandbox boots a small virtual machine. You choose how many processor cores and how much memory it gets, and what network it may use. It keeps running until you stop or delete it. Stopping keeps its disk; deleting removes everything. ``` Map c = k.createSandbox(new Options.Create().name("doc-java-guide").cpus(2).memMb(1024) .egressAllow(List.of("example.com")).labels(Map.of("team", "docs"))); System.out.println("create " + c); Sandbox sb = k.sandbox("doc-java-guide"); System.out.println("info " + sb.info()); System.out.println("egress " + sb.egress()); ``` ``` create {boot_ms=10666, sandbox={id=doc-java-guide, name=doc-java-guide, created=2026-11-03T01:39:45.161322-05:00, state=running, cpus=2, mem_mb=1024, disk_gb=32, egress=allow:example.com, ip=192.168.127.2, labels={team=docs}}} info {id=doc-java-guide, name=doc-java-guide, created=2026-11-03T01:39:45.161322-05:00, state=running, cpus=2, mem_mb=1024, disk_gb=32, egress=allow:example.com, ip=192.168.127.2, labels={team=docs}} egress {policy=allow:example.com, stats={passed=3, denied=0, dropped=2, dns_answered=2, dns_refused=0}} ``` `createSandbox` blocks until the machine has booted. If you leave out the name, read the one the daemon chose from `c.get("sandbox")`. `labels` are your own free-form tags; they are not the classification label (`CUI` and so on) that `kosa8 sandbox create --label` sets, which `Options.Create` has no setter for. ### `Options.Create` | Setter | Sent as | Meaning | |---|---|---| | `name(String)` | `name` | The sandbox's name and id. A name already in use is a `ConflictException` (409). | | `cpus(int)` | `cpus` | Virtual CPUs (the run above shows the daemon's default of 2). | | `memMb(int)` | `mem_mb` | Memory in MiB. | | `diskGb(int)` | `disk_gb` | Disk size in GB (default seen: 32). | | `mounts(List)` | `mounts`, as `{host_path, guest_path, read_only}` objects | Host folders shared in, each `host:guest`, `host:guest:ro` or `host:guest:rw`. Both paths must be absolute: the host path as `Path.isAbsolute()` sees it, the guest path starting with `/`. Any other shape, or a relative path, throws `Kosa8Exception` before sending (the absolute-path check is new in 0.1.2). Mounted paths are visible inside containers too ([example]). Broken in 0.1.0, which sent the strings as they were and got `500 mount has no guest path`. | | `egress(String)` | `egress` | `deny`, `any`, `fakenet` or `allow:host,host`. See [Network rules](https://docs.kosa8.com/guides/network/). | | `egressAllow(List)` | `egress` as `allow:…` | Only these hosts. Each is trimmed and joined with commas. Combined with any `egress` other than `deny`, the SDK throws a plain `Kosa8Exception` before sending anything. | | `capture(String hostFile)` | `capture` as `{"path": …}` | Record every frame the sandbox sends or receives, on the host, as one pcapng file at that path (opens in Wireshark); the folder must exist ([example]). An empty string throws `capture needs a host file path to write the capture to`. The file holds everything sent in the clear; treat it as secret. | | `capture(boolean)` | nothing | Deprecated. `capture(true)` throws `Kosa8Exception` before sending, because the daemon needs a path (in 0.1.0 it reached the daemon and failed with a 500). The message is quoted on the [errors page](https://docs.kosa8.com/guides/java-errors/#plain). | | `labels(Map)` | `labels` | Your own tags; they come back in `listSandboxes()`, which makes clean-up easy ([recipe]). | | `broker(Object)` | `broker` | The credential-broker configuration, passed through as JSON. See [Keep API keys out](https://docs.kosa8.com/guides/credentials/). | | `approval(String)` | `X-Kosa8-Approval` header | A signed approval, when a policy gates creation. See [Approvals]. | ### The network rule, seen from inside ``` Results.Exec net = sb.run("alpine:latest", List.of("sh", "-c", "wget -q -T 5 -O- http://example.com | head -c 60; echo; wget -q -T 5 -O- http://pypi.org >/dev/null && echo pypi ok || echo pypi blocked")); System.out.println("net: " + net.stdout()); ``` ``` net: c = k.createSandbox(new Options.Create().name("doc-java-012").egress("deny") .mounts(List.of(dir + "/mnt:/work:ro")).capture(dir + "/traffic.pcapng")); System.out.println("create " + c); sb.pull("alpine:latest"); System.out.println("mount from a container: " + sb.run("alpine:latest", List.of("sh", "-c", "cat /work/hello.txt 2>&1; ls / | tr '\n' ' '")).stdout()); ``` ``` create {boot_ms=13255, sandbox={id=doc-java-012, …, mounts=[{host_path=/private/tmp/…/p12/mnt, guest_path=/work, read_only=true}], egress=deny, capture=/private/tmp/…/p12/traffic.pcapng, ip=192.168.127.2}} mount from a container: hello from the host bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var work $ file p12/traffic.pcapng p12/traffic.pcapng: pcapng capture file - version 1.0 ``` The host folder `mnt` (holding `hello.txt`) appeared read-only at `/work` in a fresh container. The capture path became a 2.7 kB pcapng file, owner-only (mode 0600), once the sandbox was gone. Mount strings are checked before sending: ``` mount "/tmp": want host:guest or host:guest:ro mount "mnt:/work": both paths must be absolute (use Path.toAbsolutePath() for the host side) mount "…/p12/mnt:work": both paths must be absolute (use Path.toAbsolutePath() for the host side) ``` To use a folder relative to your program, build the host side with `Path.of("mnt").toAbsolutePath()`. ### Lifecycle ``` System.out.println("stop: " + sb.stop()); System.out.println("state: " + sb.info().get("state")); Map up = sb.start(); System.out.println("start: state=" + ((Map) up.get("sandbox")).get("state") + " boot_ms=" + up.get("boot_ms")); ``` ``` stop: {ok=true, sandbox={id=doc-java-sweep, name=doc-java-sweep, created=2026-11-03T01:51:31.468078-05:00, state=stopped, cpus=2, mem_mb=1024, disk_gb=32, egress=deny, labels={owner=doc-java}}, warning=} state: stopped start: state=running boot_ms=743 ``` | Call | HTTP | Notes | |---|---|---| | `listSandboxes()` | `GET /kosa8/v1/sandboxes` | Every sandbox, as maps. | | `getSandbox(idOrName)` / `sb.info()` | the same list | Filtered in the client: first by `id`, then by `name`. `null` when there is none; it does not throw. | | `stopSandbox` / `sb.stop()` | `POST …/down` | Shuts the machine down; the disk is kept. Running processes are lost. | | `startSandbox` / `sb.start()` | `POST …/up` | Boots a stopped sandbox from its disk. On a sandbox that does not exist, kosa8 0.9.36 answers 500, so this is a plain `ApiException`, not `NotFoundException`. | | `deleteSandbox` / `sb.delete()` | `DELETE …` | Then removes the sandbox's checkpoint records and their snapshots (below). | **What delete does, step by step.** It sends `DELETE /kosa8/v1/sandboxes/{id}` (with `?wipe=1` if you asked for `Options.Delete().wipe(true)`, which overwrites the disks instead of only unlinking them). If that fails, the exception is thrown and nothing else happens. If it succeeds, and you did not ask for `keepCheckpoints(true)`, the SDK removes the sandbox's stack from the checkpoint store and deletes each of those snapshots, ignoring individual failures. The answer is the daemon's map plus `checkpoints_dropped`, the number of records removed. ## Running code **In plain words.** Inside a sandbox, programs run in containers, the same kind Docker uses. You can run a command and wait for it, keep a container alive and run several commands in it, feed a program input, or watch output as it happens. **First, an image.** A container needs an image, and the image must be inside the sandbox. `sb.pull(ref)` (`pullIntoSandbox`) fetches it on the host and copies it in, so it works even with the network denied. `k.pull(ref)` pulls into the host's shared engine instead, not into any sandbox. `sb.images()` lists what a sandbox holds: ``` pull {image=docker.io/library/alpine:latest, ok=true} images [docker.io/library/alpine:latest] ``` Running an image that was never pulled does not fail with a 404. The daemon starts the stream and then ends it with an error, so it is a `StreamException`: ``` unpulled: com.kosa8.Kosa8Exception$StreamException status=0: image "docker.io/library/python:3.12-alpine" not found in engine: image "docker.io/library/python:3.12-alpine": not found ``` ### The four ways to run | Method | Use it when | Returns | |---|---|---| | `run(sandbox, image, args, Options.Run)` | One command in a fresh container; you want its output when it ends. | `Results.Exec` | | `startContainer(sandbox, image, args)` then `exec(sandbox, container, args, Options.Run)` | Several commands that share files or running processes. | a container id, then `Results.Exec` per command | | `startInteractive`, `writeStdin`, `attach` | A program that reads its input from stdin. | a container id, `{ok=true}`, then `Results.Exec` when it exits | | `execFrames(sandbox, container, args, Consumer, Options.Approval)` | You want the raw stream, frame by frame, including the final one. | nothing; your consumer sees every frame | `Results.Exec` is `(int exitCode, byte[] stdoutBytes, byte[] stderrBytes)` with `stdout()` and `stderr()` decoding as UTF-8, and `ok()` meaning `exitCode == 0`. **A non-zero exit code is a result, not an exception.** The call throws only if the command could not run or the stream broke. ### Environment variables and live output ``` Results.Exec r = sb.run("alpine:latest", List.of("sh", "-c", "echo GREETING=$GREETING; echo oops >&2; for i in 1 2 3; do echo tick $i; sleep 0.3; done; exit 0"), new Options.Run().env(Map.of("GREETING", "hello")) .onStdout(b -> System.out.print("[out] " + new String(b, StandardCharsets.UTF_8))) .onStderr(b -> System.out.print("[err] " + new String(b, StandardCharsets.UTF_8))) .onStatus(b -> System.out.print("[status] " + new String(b, StandardCharsets.UTF_8)))); System.out.println("run exit=" + r.exitCode() + " ok=" + r.ok() + " stdout=" + r.stdout().replace("\n", "\\n") + " stderr=" + r.stderr().replace("\n", "\\n")); ``` ``` [err] oops [out] GREETING=hello tick 1 [out] tick 2 [out] tick 3 run exit=0 ok=true stdout=GREETING=hello\ntick 1\ntick 2\ntick 3\n stderr=oops\n ``` Three things to notice, all real behaviour: - **Callbacks get chunks, not lines.** The first stdout chunk held two lines. If you need lines, buffer and split yourself. - **Order across streams is not kept.** The script wrote to stdout first, yet the stderr chunk arrived first; in a re-run on 0.1.1 it came second. Within one stream the order is kept. - **The full output is still collected.** The callbacks are extra; `r.stdout()` has everything. Callbacks run on the thread that called `run`, between reads, so a slow callback slows the stream down. `env` takes a `Map` or a `List` of `KEY=VALUE` strings. Only `run` can use it. `exec` with a non-empty `env` throws `Kosa8Exception` before sending, because the daemon's exec takes no environment (0.1.0 silently dropped it): ``` exec env: exec does not take env: the container keeps the environment it was started with. Set it at startInteractive, or wrap the command: List.of("env", "K=V", "cmd") ``` `attach` ignores it. For `exec`, put variables in the command (`env KEY=V cmd`) or set them when starting the container with `startInteractive(…, new Options.Interactive().env(…))`. `onStatus` receives frames on the `status` stream (pull and progress lines); this run produced none. ### A container that stays up ``` String cid = sb.startContainer("alpine:latest", List.of("sleep", "infinity")); Results.Exec r = sb.exec(cid, List.of("sh", "-c", "echo one > /f; cat /f"), null); System.out.println(sb.containers()); ``` `startContainer` returns as soon as the container is started, with its id (such as `k3-745c8bcf`). `containers()` lists every container with its `id`, `image`, `status` (`running`, `exited`) and `ip`. An exec into a container that does not exist is a `StreamException` (`no such container: nope`), for the same reason as an unpulled image. ### Feeding stdin ``` String ic = sb.startInteractive("alpine:latest", List.of("sh", "-c", "tr a-z A-Z"), null); System.out.println("stdin " + sb.writeStdin(ic, "hello stdin\n".getBytes(StandardCharsets.UTF_8), null)); try { sb.writeStdin(ic, "again\n".getBytes(StandardCharsets.UTF_8), null); } catch (Kosa8Exception e) { System.out.println("second stdin: " + e.getMessage()); } System.out.println("attach " + sb.attach(ic, null).stdout().strip()); ``` ``` stdin {ok=true} second stdin: stdin of container k3-d6cc9b51 in doc-java-011 was already sent and is closed: the daemon treats one writeStdin body as the whole of stdin. Pass an InputStream to stream input attach HELLO STDIN ``` `startInteractive` starts a container with stdin held open (with `Options.Interactive().tty(true)` it gets a terminal). `writeStdin` sends the bytes; when the body ends, the program's stdin is closed, which is why `tr` finished. **One body is the whole of stdin**, so a second `writeStdin` to the same container from the same client throws, as above (0.1.0 answered ok for input the program never saw). The check is kept per client, in memory: a second client, or a new JVM, is not stopped by it, and the daemon still discards such input. To feed input over time, pass one `InputStream` that produces bytes as you go, such as a `PipedInputStream`. `attach` follows the container's output until it exits. `writeStdin` also takes an `InputStream`, which it sends with chunked transfer encoding, 16 KiB at a time, until the stream returns end-of-file (the byte-array form was the one run here). Since 0.1.1 the `Sandbox` handle has `startInteractive`, `writeStdin`, `attach` and `execFrames` as well as the client. ### Raw frames Underneath, `run`, `exec` and `attach` read a stream of JSON lines (NDJSON). Each line is a `Frame`: `(stream, data, done, ok, error, exit, id)`, with `data` already decoded from base64. `execFrames` hands you each one: ``` String svc = sb.startContainer("alpine:latest", List.of("sleep", "3600")); k.execFrames("doc-java-guide", svc, List.of("sh", "-c", "echo a; echo b >&2; exit 4"), f -> System.out.println("frame stream=" + f.stream() + " data=" + new String(f.data(), StandardCharsets.UTF_8).replace("\n", "\\n") + " done=" + f.done() + " ok=" + f.ok() + " exit=" + f.exit() + " error=" + f.error() + " id=" + f.id()), null); ``` ``` frame stream=stderr data=b\n done=false ok=false exit=null error=null id=null frame stream=stdout data=a\n done=false ok=false exit=null error=null id=null frame stream=null data= done=true ok=true exit=4 error=null id=null ``` The last frame has `done=true`. Its `ok` means the *stream* ended cleanly, not that the command succeeded: `ok=true` with `exit=4`. A final frame with `error` set is how the daemon reports a command that could not run, and the collecting methods turn it into a `StreamException`. `execFrames` does not: it passes the error frame to you and returns normally. It also takes no timeout. ## Timeouts **In plain words.** A timeout stops your program from waiting forever. It does not stop the command in the sandbox. ``` try { sb.run("alpine:latest", List.of("sleep", "30"), new Options.Run().timeout(Duration.ofSeconds(2))); } catch (Kosa8Exception.TimeoutException e) { System.out.println("timeout: " + e.getMessage()); } Thread.sleep(1000); System.out.println("containers after timeout " + sb.containers()); ``` ``` timeout: POST /kosa8/v1/sandboxes/doc-java-guide/run timed out talking to unix:///Users/dhruvit/.kosa8/run/kosa8.sock after 2012ms containers after timeout [{id=k3-745c8bcf, image=docker.io/library/alpine:latest, status=running, ip=10.88.0.4}] ``` (The "after 2012ms" was added by the test program.) **In detail:** a timeout is a watchdog on one shared daemon thread (`kosa8-timeout`). When it fires it closes the connection, and the blocked read fails with `TimeoutException`. The `sleep 30` container was still running afterwards. Stop such a container yourself, or delete the sandbox. A `TimeoutException` carries no output: if you need what was printed before the limit, collect it with `onStdout`/`onStderr`. | Where | Applies to | Default | |---|---|---| | `Options.Run.timeout(Duration)` | That one `run`, `exec` or `attach`. | the client's | | `Builder.timeout(Duration)` | Every request without its own, including `createSandbox`, `snapshot` and restores. Booting took 10–16 seconds and one restore 31 seconds on the busy test machine, so set this generously or not at all. | none | | `Options.Explore.timeout(Duration)` | Each branch's command in `explore`. | 120 seconds | A zero `Duration` means no limit. The limit is measured from when the connection opens, so it covers sending the request too. ## Snapshots **In plain words.** A snapshot is a photograph of a whole running sandbox: memory, running programs and disk. You can start a new sandbox from it later, as many times as you like, and every copy carries on from exactly that moment. ``` var snap = sb.snapshot(new Options.Snapshot().id("doc-java-snap-manual")); var restored = k.restoreSnapshot("doc-java-snap-manual", new Options.Restore().name("doc-java-snap-copy")); System.out.println("copy /f=" + k.exec("doc-java-snap-copy", cid, List.of("cat", "/f"), null).stdout().strip()); System.out.println("delete snap " + k.deleteSnapshot("doc-java-snap-manual", null)); ``` ``` snapshot {snapshot={id=doc-java-snap-manual, sandbox=doc-java-snap, created=2026-11-03T01:42:22.741039-05:00, …, size_bytes=106958848, state_path=/Users/dhruvit/.kosa8/snapshots/doc-java-snap-manual/state.vzvmsave, …, egress=deny}, snapshot_ms=14794} restored {restore_ms=31285, sandbox={id=doc-java-snap-copy, name=doc-java-snap-copy, created=2026-11-03T01:42:54.029023-05:00, state=running, parent=doc-java-snap, cpus=2, mem_mb=1024, disk_gb=32, egress=deny, ip=192.168.127.2}} copy /f=one delete snap {encrypted=false, ok=true, removed=doc-java-snap-manual} ``` The copy has the original's container, with the same id, still running and holding the same file. `listSnapshots()` returns every snapshot on the machine (checkpoints included, since they are snapshots too). | Options | Setter | Meaning | |---|---|---| | `Snapshot` | `id(String)` | The snapshot's id; the daemon picks one if unset. | | `encrypt(boolean)` | Encrypt the snapshot at rest once it is taken, as `kosa8 snapshot create --encrypt`. An org policy with `require_snapshot_encryption` does it anyway. (Not run for this page.) | | | `Restore` | `name(String)` | The new sandbox's name. | | `label(String)` | Raise the restored sandbox's classification label above the snapshot's. Lower or sideways is refused. Not a free-form tag. | | | `allowTampered(boolean)` | Restore a sealed snapshot whose seal does not verify, to examine the tampering. Recorded in the audit log. | | | `SnapshotDelete` | `wipe(boolean)` | Overwrite the files, not just unlink them (`?wipe=1`). | | `force(boolean)` | `?force=1`. | | ## Fork and explore **In plain words.** Forking makes live copies of a running sandbox, so you can try several things side by side. Explore does that for you: one copy per idea, run them all at once, keep the copy where the idea worked fastest, throw the others away. ``` var f = sb.fork(2, new Options.Fork().prefix("doc-java-fork")); System.out.println("fork " + f); ``` ``` fork Fork[forks=[{id=doc-java-fork1, name=doc-java-fork1, …, state=running, parent=doc-java-snap, …}, {id=doc-java-fork2, name=doc-java-fork2, …, state=running, parent=doc-java-snap, …}], snapshot=doc-java-snap-fork-1793688195516612000, forkMs=3356, count=2] ``` `fork(sandbox, n, Options.Fork)` sends `POST …/fork` with `n` (at least 1; a smaller value is raised to 1). Forks are named `prefix` plus a number, as `doc-java-fork1`, `doc-java-fork2` above. `Options.Fork().label(…)` raises the forks' classification above the parent's (never lower); it is not a free-form tag. The forks are ordinary sandboxes: delete them when done. ``` var ex = sb.explore(List.of("sleep 2; echo slow", "echo fast", "exit 1"), new Options.Explore().pull(false).timeout(Duration.ofSeconds(60))); System.out.println("explore parent=" + ex.parent() + " winner=" + ex.winner() + " removed=" + ex.removed()); for (var b : ex.branches()) System.out.println(" " + b); ``` ``` explore parent=doc-java-snap winner=doc-java-snap-explore-881982 removed=2 ExploreBranch[name=doc-java-snap-explore-881981, variant=sleep 2; echo slow, exit=0, output=slow, error=null, durationMs=3586] ExploreBranch[name=doc-java-snap-explore-881982, variant=echo fast, exit=0, output=fast, error=null, durationMs=1603] ExploreBranch[name=doc-java-snap-explore-881983, variant=exit 1, exit=1, output=, error=null, durationMs=1603] ``` **What explore does, step by step** (it is client-side, the same algorithm as `kosa8 explore`): 1. Throws at once if there are fewer than two variants. 2. Unless `pull(false)`, pulls `image` (default `alpine:latest`) into the sandbox. 3. Forks the sandbox once per variant, with prefix `prefix` or `-explore-<5 digits of the clock>`. If fewer forks come back than variants, it throws and leaves what was created for you to find with `listSandboxes()`. 4. Runs `sh -c VARIANT` in each fork at the same time, on a thread pool with one thread per variant, each with the branch timeout. A connection-level failure (refused, reset, EOF, …) is retried up to four times with a growing pause, since a just-restored branch can refuse its first connection. A timeout is not retried. 5. The winner is the fastest branch with exit 0 and no error. `keep(name)` overrides that; a name that is not one of the branches throws, after the run, with every branch kept. 6. Unless `keepAll(true)`, deletes every branch except the winner. `removed` counts the deletions that succeeded. When no branch succeeded, `winner` is `""` and every branch is deleted. The winner is left running and is yours to delete. The parent sandbox is untouched. ## Checkpoints **In plain words.** A checkpoint is an "undo" point. Take one before something risky; if it goes wrong, rewind, and the sandbox is back as it was, programs still running. The SDK and the `kosa8` command line share the same list, so a checkpoint taken in Java can be rewound from the terminal and the other way round. ``` sb.checkpoint("taken from Java"); // …then, from the terminal: kosa8 checkpoint doc-java-shared --label 'taken from the CLI' for (Checkpoint c : sb.checkpoints()) System.out.println(c.id() + " label=\"" + c.label() + "\" auto=" + c.auto() + " created=" + c.created()); System.out.println("rewind to: " + sb.rewind("taken from java").checkpoint().id()); ``` ``` $ kosa8 checkpoint doc-java-shared --label 'taken from the CLI' checkpoint doc-java-shared-ckpt-2 (taken from the CLI) saved in 545ms rewind with `kosa8 rewind doc-java-shared` doc-java-shared-ckpt-1 label="taken from Java" auto=false created=2026-11-03T06:52:55.215776Z doc-java-shared-ckpt-2 label="taken from the CLI" auto=false created=2026-11-03T06:52:55.837825Z rewind to: doc-java-shared-ckpt-1 $ kosa8 checkpoint ls doc-java-shared CHECKPOINT WHEN TAKEN WHY *doc-java-shared-ckpt-1 just now you taken from Java * is where `kosa8 rewind doc-java-shared` goes deleted: {ok=true, checkpoints_dropped=1} ``` Java saw the CLI's checkpoint, rewound to its own by label (matched without regard to case: `"taken from java"` found `"taken from Java"`), and the CLI then saw that the later checkpoint had been dropped. ### How checkpoints work The daemon has snapshots; checkpoints are bookkeeping the client keeps on top of them, exactly as the CLI's own `pkg/checkpoint` does. - **A stack per sandbox.** Each checkpoint is a snapshot with the id `-ckpt-`, where `n` is one more than the highest number on the stack (or the stack's size plus one, whichever is larger). - **At most 10.** Taking the eleventh evicts the oldest, after the new snapshot exists, so a failed checkpoint never costs you the old one. `CheckpointSaved.evicted()` lists what went. (From the source and the SDK's mock-daemon test; not run live.) - **Rewind** (`to`: an id or a label; `null` means the latest) does three things in order: deletes the sandbox, restores the checkpoint's snapshot under the same name, then drops the checkpoints taken after the target and deletes their snapshots. The current state is lost. If the delete is refused, nothing has changed and that exception is thrown as is. If the restore fails, the sandbox no longer exists, the checkpoint is intact, and you get a `CheckpointException` whose message says so and contains the exact `restoreSnapshot(…)` call that recovers it ([real message](https://docs.kosa8.com/guides/java-errors/#rewind-failed)). **Two at once.** Two checkpoints of one sandbox taken at the same moment (two threads, two clients, or Java and the CLI) can pick the same next id. The daemon refuses the second snapshot with 409 `already exists`, and the SDK steps to the next number and tries again, up to 10 times. Any other 409 is thrown. Run with two clients in two threads: ``` concurrent: doc-java-011-ckpt-1 (client one), doc-java-011-ckpt-2 (client two) ``` - **Names are checked.** An empty name, one starting with `.`, or one containing `/` or `\` is refused (`invalid sandbox name "../etc"`) before any request is sent. - **`auto`** may be set on records written by kosa8's own Go tools. The SDK always records false, and leaves the field out of the file when it is false, as the CLI does. ### Where the list lives, and the locking caveat By default the client uses `FileCheckpointStore`, which reads and writes the CLI's own file, `~/.kosa8/checkpoints.json`. Every write goes to `checkpoints.json.tmp`, is set to mode 0600, and is renamed over the file atomically, so a reader never sees half a file. A file that cannot be parsed is treated as empty rather than breaking your sandbox. To keep two writers from overwriting each other's changes, each update first takes an in-process lock shared by every `FileCheckpointStore` on that path in the JVM (so two clients in one program queue up instead of failing), then an exclusive lock on `checkpoints.json.lock`. It waits at most **10 seconds** for both together, then throws `CheckpointException`. Choose a different limit with `new FileCheckpointStore(path, Duration lockTimeout)`. With another process holding the lock and a 2-second limit: ``` com.kosa8.Kosa8Exception$CheckpointException after 2021ms: timed out after 2s waiting for /private/tmp/…/ck/checkpoints.json.lock; another kosa8 process is holding it ``` Here is the caveat about the CLI, precisely: - **Java can only take `fcntl` locks** (that is what `FileChannel.tryLock()` uses on Unix). **The CLI takes `flock` locks.** These are two different locking systems in the operating system. - **On Linux they do not see each other.** A Java checkpoint, rewind or delete that updates the file at the very instant a CLI command updates it can lose one of the two records. (From the SDK's documentation and Linux's locking rules; not reproduced for this page.) - **On macOS they do exclude each other.** We held a `flock` on the lock file from another process for 6 seconds and ran a Java update; it waited: ``` java update took 8ms # no other lock held python holds flock for 6s java update took 5172ms # waited for the flock to be released ``` - **Java processes always exclude each other**, on both systems, and so do clients within one JVM. The window is one file rewrite, a few milliseconds, so in practice it matters only on Linux, when scripts and the CLI checkpoint the same machine at the same moment. If that is your situation, or you want the SDK's checkpoints to be private, use a store of your own: ``` // In memory: nothing shared with the CLI, gone when the JVM exits. Kosa8Client k = Kosa8Client.builder().checkpoints(new MemoryCheckpointStore()).build(); // A different file, same format and locking. Kosa8Client k2 = Kosa8Client.builder() .checkpoints(new FileCheckpointStore(Path.of("/srv/agent/checkpoints.json"))).build(); ``` Or implement `CheckpointStore` yourself: `load()` returns a copy of all stacks (sandbox name to list, oldest first), and `update(fn)` must run `fn` on a mutable copy and save what it leaves, excluding other updates while it does. ## Thread safety **In plain words.** One client can be shared by many threads, and several clients can live in one program. Calls do not get in each other's way. ``` ExecutorService pool = Executors.newFixedThreadPool(4); List> out = new ArrayList<>(); for (int i = 1; i <= 4; i++) { int n = i; out.add(pool.submit(() -> sb.exec(cid, List.of("sh", "-c", "echo job " + n + " squared is $((" + n + "*" + n + "))"), null).stdout().strip())); } for (Future f : out) System.out.println(f.get()); ``` ``` job 1 squared is 1 job 2 squared is 4 job 3 squared is 9 job 4 squared is 16 ``` **In detail:** | Part | Safe to share? | Why | |---|---|---| | `Kosa8Client`, `Sandbox` | Yes | All fields are final and set in `build()`. Each call opens its own connection, so calls do not share any stream. | | `Results.*`, `Checkpoint`, `Frame`, `Endpoint` | Yes, read-only | Records. `Exec` exposes its `byte[]` arrays directly; do not modify them if others read them. | | `Options.*` | Build per call | Mutable builders with no locking. | | Checkpoints of the *same* sandbox from two threads or clients | Yes | Both can pick the same next id; the one the daemon refuses with 409 steps to the next (seen: `ckpt-1` and `ckpt-2`). Each still waits its turn for the store's lock. | | Two clients with default stores in one JVM | Yes | Each `build()` without `checkpoints(…)` makes a new `FileCheckpointStore`, but stores on one path share one in-process lock, so the second waits (seen: 1027 ms behind a 1-second update, no exception). In 0.1.0 it threw `OverlappingFileLockException`. | | The `writeStdin` once-only check | Per client | Kept in a concurrent set on the client. Another client does not know what this one sent. | Streaming callbacks run on the calling thread. `explore` starts its own pool, sized to the number of variants, and shuts it down before returning. ## Resources and clean-up Neither `Kosa8Client` nor `Sandbox` implements `AutoCloseable`, so there is no try-with-resources form. The client holds no open connection and needs no closing. What does need cleaning up is what you create in the daemon: sandboxes, forks, explore winners and snapshots. They outlive your program. Use `try` … `finally`, as in the quickstart, and [sweep by label] to catch anything left by a crash. If you want try-with-resources, a three-line wrapper of your own does it: ``` record TempSandbox(Sandbox sb) implements AutoCloseable { @Override public void close() { sb.delete(); } } // try (var t = new TempSandbox(k.sandbox("x"))) { t.sb().run(…); } ``` ## Approvals and policy A company [policy](https://docs.kosa8.com/guides/governance/) can forbid an action, or require a signed approval for it. Every option class extends `Options.Call`, so any gated call takes `.approval(sig)`; calls whose only option is an approval take `Options.Approval.of(sig)`. The signature is sent as the `X-Kosa8-Approval` header. A refusal that an approval would lift is an `ApprovalRequiredException`, whose `approval()` map is the daemon's explanation (`why`, who may sign, the command to request one). On the `Sandbox` handle, methods that take an options object carry the approval in it; `start`, `stop`, `pull`, `startContainer`, `checkpoint` and `rewind` take none, so use the client's overloads for those. (From the source; no policy was installed for this page.) ## Recipes ### An agent loop with undo Checkpoint before each step an agent proposes; if the step fails, rewind so the next step starts from a known-good machine. A failed step costs nothing. ``` import com.kosa8.*; import java.util.List; import java.util.Map; public class AgentLoop { public static void main(String[] args) { Kosa8Client k = Kosa8Client.builder().actor("agent").build(); String name = "doc-java-agent"; k.createSandbox(new Options.Create().name(name).egress("deny").labels(Map.of("owner", "doc-java"))); Sandbox sb = k.sandbox(name); try { sb.pull("alpine:latest"); String cid = sb.startContainer("alpine:latest", List.of("sleep", "infinity")); // The steps an agent proposed. One of them is a mistake. List steps = List.of( "mkdir -p /work && echo 'print(1)' > /work/app.py", "rm -rf /work && false", // destroys the work, then fails "echo 'print(2)' >> /work/app.py"); for (String step : steps) { sb.checkpoint("before: " + step); Results.Exec r = sb.exec(cid, List.of("sh", "-c", step), null); if (r.ok()) { System.out.println("ok " + step); } else { Results.Rewind rw = sb.rewind(null); // undo exactly this step System.out.println("undone " + step + " (exit " + r.exitCode() + ", back to " + rw.checkpoint().id() + ")"); } } System.out.print("result:\n" + sb.exec(cid, List.of("cat", "/work/app.py"), null).stdout()); System.out.println("checkpoints kept: " + sb.checkpoints().size()); } finally { System.out.println("deleted: " + sb.delete()); } } } ``` ``` ok mkdir -p /work && echo 'print(1)' > /work/app.py undone rm -rf /work && false (exit 1, back to doc-java-agent-ckpt-2) ok echo 'print(2)' >> /work/app.py result: print(1) print(2) checkpoints kept: 3 deleted: {ok=true, checkpoints_dropped=3} ``` The container id stayed valid across the rewind, because the rewind brings back the running container. `actor("agent")` makes the audit log show these calls as an agent's. (The run that printed this also ran the thread-pool example above before deleting; those lines are left out.) ### Sweep up leftovers by label Label everything your program creates, and delete by label at start-up or in a cron job. This catches sandboxes left behind by a crash or a killed JVM. ``` /** Delete every sandbox labelled owner=OWNER, with its checkpoints. */ static List sweep(Kosa8Client k, String owner) { List gone = new ArrayList<>(); for (Map s : k.listSandboxes()) { if (s.get("labels") instanceof Map l && owner.equals(l.get("owner"))) { String id = (String) s.get("id"); try { k.deleteSandbox(id); gone.add(id); } catch (Kosa8Exception.NotFoundException e) { // already gone } } } return gone; } ``` ``` swept: [doc-java-sweep] ``` Forks and explore branches carry a `parent` field in `listSandboxes()` and are named after a prefix, so a sweep can also match on either of those. (Whether forks copy their parent's `labels` was not checked for this page.) ## The same thing from the command line | Java | CLI | |---|---| | `k.createSandbox(new Options.Create().name("x").egress("deny"))` | `kosa8 sandbox create x --egress deny` | | `k.listSandboxes()` | `kosa8 sandbox ls` | | `sb.checkpoint("why")` | `kosa8 checkpoint x --label why` | | `sb.checkpoints()` | `kosa8 checkpoint ls x` | | `sb.rewind(null)` | `kosa8 rewind x` | | `sb.explore(List.of("a", "b"), null)` | `kosa8 explore x --variant a --variant b` | | `k.listSnapshots()` | `kosa8 snapshot ls` | Every call made from Java appears in `kosa8 audit ls` with the actor you set. **Next** [Errors and troubleshooting](https://docs.kosa8.com/guides/java-errors/): every exception and what to do about it. [API reference](https://docs.kosa8.com/guides/java-api/): every class and method. [SDKs in five languages](https://docs.kosa8.com/guides/sdks/) compares Java with the others. --- Source: https://docs.kosa8.com/guides/java-guide/ # Java errors and troubleshooting Everything the SDK throws is a `Kosa8Exception`, and an error from the daemon keeps the daemon's own words: what went wrong, and what to do about it. This page lists each class, when it is thrown and how to handle it, then the problems you are likely to meet, from symptom to fix. **How this page was tested** This page describes SDK **0.1.2** (`com.kosa8:kosa8:0.1.2` on Maven Central). The capture and mount messages, which 0.1.2 changed or added, were printed by 0.1.2 built from the `v0.1.2` tag (commit 14e5fc6) with `mvn install`, because Maven Central was still answering 404 for 0.1.2 while it was being published. Each message in a terminal block was printed by a Java program on OpenJDK 21.0.12.1, against kosa8 daemon 0.9.36 on an Apple silicon Mac (macOS 26.0.1). The messages that 0.1.1 added or changed (the failed rewind, the lock timeout, `capture(true)`, exec with env, a second `writeStdin`, a bad mount) came from 0.1.1, built from its release tag just before it was published; the published jar carries the same messages (checked in its class files). The rest came from 0.1.0 from Maven Central, on code paths 0.1.1 did not change; the run, exec, stdin and timeout ones were re-run on 0.1.1 and printed the same. What changed between the two versions is listed [at the end]. `AuthException`, `PolicyException`, `ApprovalRequiredException` and `UnsupportedException` were not produced: that would have meant installing an org policy or a remote worker on a shared test machine. Their sections are from the SDK's source, and say so. The locked-screen rewind failure is quoted from the Go SDK's run of the same daemon. ## What this is, in plain words When something goes wrong, the SDK throws an exception. Java has two kinds: **checked** exceptions, which the compiler makes you catch or declare, and **unchecked** ones, which it does not. All of this SDK's exceptions are **unchecked**: your code compiles without any `try`/`catch`, and you catch only what you want to handle. Each exception says what happened in its message. When the daemon refused something, the message also carries the daemon's advice in brackets, and the same two parts are available separately as `daemonMessage()` and `remediation()`. One thing is *not* an exception: a command that runs and exits with a non-zero code. That is a normal result; check `result.exitCode()` or `result.ok()`. ## The hierarchy All classes are `public static` classes nested inside `com.kosa8.Kosa8Exception`, so you write `Kosa8Exception.NotFoundException`, or import them individually (`import com.kosa8.Kosa8Exception.NotFoundException;`). ``` java.lang.RuntimeException unchecked └─ Kosa8Exception anything this SDK throws; used as is for client-side checks ├─ ConnectionException the daemon could not be reached ├─ ApiException the daemon answered with an HTTP error (any status, e.g. 500) │ ├─ BadRequestException 400 │ ├─ AuthException 401 │ ├─ PolicyException 403 │ │ └─ ApprovalRequiredException 403 that a signed approval would lift │ ├─ NotFoundException 404 │ ├─ ConflictException 409 │ └─ UnsupportedException 501 ├─ StreamException a run, exec or attach stream ended with an error, or broke ├─ CheckpointException checkpoint bookkeeping: none to rewind to, bad name, failed restore └─ TimeoutException a request ran out of time ``` **Two names that clash** `Kosa8Exception.TimeoutException` is not `java.util.concurrent.TimeoutException` (which is checked). If you import both, refer to the SDK's by its qualified name. Likewise, `Kosa8Exception` itself extends `RuntimeException`, so a `catch (RuntimeException e)` placed first will swallow it. ## Fields | Accessor | On | Meaning | |---|---|---| | `getMessage()` | all | For a daemon error: `" ()"`, the way the CLI prints it; just `` when there is no remediation; `"request failed with HTTP "` when the body had no text. | | `status()` | all | The HTTP status for an `ApiException`; `0` for everything else (the daemon did not answer with an error status). | | `daemonMessage()` | `ApiException`, `StreamException` | The daemon's `error` text (or `message` if there is no `error`), verbatim. For a stream, the error frame's text. `null` otherwise. | | `remediation()` | `ApiException` | The daemon's advice, or `null`. | | `method()`, `path()` | `ApiException`, `ConnectionException`, `TimeoutException` | The request that failed, as `POST` and `/kosa8/v1/sandboxes/x/run`. `null` on the others. | | `body()` | `ApiException` | The decoded response body: a `Map` for JSON, a `String` otherwise. | | `getCause()` | `ConnectionException`, some `CheckpointException`s and plain ones | The underlying exception, as `java.net.SocketException`, or the restore failure inside a failed rewind. | | `stdout()`, `stderr()` | `StreamException` | Output produced before the stream ended, decoded as UTF-8. Never `null`. | | `approval()` | `ApprovalRequiredException` | The daemon's refusal as a map: `why`, who may sign, how to request one. | The program that printed the messages on this page used this helper: ``` static void show(String what, Kosa8Exception e) { System.out.println(what + ": " + e.getClass().getName().replace("com.kosa8.", "") + " status=" + e.status() + " method=" + e.method() + " path=" + e.path() + "\n message: " + e.getMessage() + "\n daemonMessage: " + e.daemonMessage() + "\n remediation: " + e.remediation()); } try { k.deleteSandbox("no-such-sandbox"); } catch (Kosa8Exception e) { show("delete missing", e); } ``` ## Each class ### `ConnectionException` **When:** the request never reached a daemon: no socket file, nothing listening, a socket path too long for the operating system, a worker that does not answer within the 30-second connect limit, or a reply that was not HTTP. ``` no daemon: Kosa8Exception$ConnectionException status=0 method=GET path=/kosa8/v1/health message: kosa8 daemon unreachable at unix:///tmp/no-daemon-here.sock: SocketException: No such file or directory (is kosa8 installed and its daemon running? start it with `kosa8 daemon start`) cause: java.net.SocketException: No such file or directory ``` **Handle:** start the daemon or fix the address. The hint in brackets is added when the cause looks like "nothing there". Nothing was done, so it is safe to retry once the daemon is up. ### `ApiException` (any other status) **When:** the daemon answered with an HTTP status of 400 or more that has no subclass of its own, mostly 500. kosa8 0.9.36 uses 500 for some "not found" cases, so these are not `NotFoundException`: ``` start missing: Kosa8Exception$ApiException status=500 method=POST path=/kosa8/v1/sandboxes/no-such-sandbox/up message: no such sandbox: no-such-sandbox (run `kosa8 doctor`; check ~/.kosa8/log/) restore missing: Kosa8Exception$ApiException status=500 method=POST path=/kosa8/v1/snapshots/no-such-snap/restore message: no such snapshot: no-such-snap (list snapshots with `kosa8 snapshot ls`) ``` It is also what a sandbox that fails to boot looks like on a busy machine: ``` Exception in thread "main" com.kosa8.Kosa8Exception$ApiException: guest not ready on vsock port 1024 after 25s (run `kosa8 doctor`; check ~/.kosa8/log/) at com.kosa8.Kosa8Exception.fromResponse(Kosa8Exception.java:158) at com.kosa8.Transport.json(Transport.java:262) at com.kosa8.Kosa8Client.createSandbox(Kosa8Client.java:142) ``` **Handle:** read the message. Catch `ApiException` to handle every daemon refusal in one place; the subclasses below are all `ApiException`s. ### `BadRequestException` (400) **When:** the daemon refused a value, most often an egress rule. ``` bad egress: Kosa8Exception$BadRequestException status=400 method=POST path=/kosa8/v1/sandboxes message: egress allow "*.com": com is a public suffix, under which anyone can register a name, so the wildcard would admit strangers' hosts; allow a domain below it, as in *.example.com remediation: null ``` **Handle:** fix the input; retrying the same request fails the same way. ### `AuthException` (401) **When:** a remote worker rejected the bearer token, or none was sent. (From the source; not produced for this page.) The token is never sent over a unix socket, so this comes only from a worker. **Handle:** check the token. A common cause: `KOSA8_TOKEN` is read only when the host also came from `KOSA8_HOST`. If your code calls `builder().host(…)`, it must call `token(…)` too. ### `PolicyException` (403) **When:** the org [policy](https://docs.kosa8.com/guides/governance/) forbids the action: a registry that is not allowed, an egress rule wider than the policy permits, and so on. (From the source; not produced for this page.) **Handle:** the message names the policy and the rule. Retrying will not help; change the request or the policy (`kosa8 policy show`). ### `ApprovalRequiredException` (403) **When:** a 403 whose body carries an `approval` object with a `why` string: the action is allowed once someone signs for it. It extends `PolicyException`, so catch it first. (From the source; not produced for this page.) ``` try { k.pull("ghcr.io/acme/tool:1"); } catch (Kosa8Exception.ApprovalRequiredException e) { System.out.println("needs approval: " + e.approval().get("why")); // once signed: k.pull("ghcr.io/acme/tool:1", Options.Approval.of(signature)); } ``` ### `NotFoundException` (404) ``` delete missing: Kosa8Exception$NotFoundException status=404 method=DELETE path=/kosa8/v1/sandboxes/no-such-sandbox message: no such sandbox: no-such-sandbox (list sandboxes with `kosa8 sandbox ls`) run missing: Kosa8Exception$NotFoundException status=404 method=POST path=/kosa8/v1/sandboxes/no-such-sandbox/run message: no such sandbox: no-such-sandbox (list sandboxes with `kosa8 sandbox ls`) delete snap missing: Kosa8Exception$NotFoundException status=404 method=DELETE path=/kosa8/v1/snapshots/no-such-snap message: no such snapshot: no-such-snap (list snapshots with `kosa8 snapshot ls`) ``` **Handle:** in clean-up code, a `NotFoundException` from a delete usually means "already gone" and can be ignored. `getSandbox` and `sb.info()` never throw this: they return `null`. ### `ConflictException` (409) ``` dup: com.kosa8.Kosa8Exception$ConflictException 409: sandbox doc-java-snap already exists (choose another name, or remove the existing sandbox with `kosa8 sandbox rm NAME` (`kosa8 sandbox ls` lists them)) ``` **Handle:** pick another name, or reuse the existing sandbox. ### `UnsupportedException` (501) **When:** the daemon (or this platform) does not support the operation. (From the source; not produced for this page.) ### `StreamException` **When:** `run`, `exec` or `attach` started streaming and then ended with an error frame, broke mid-stream, sent a line that is not a valid frame, or ended without a final frame. Because the daemon reports some failures inside the stream rather than as an HTTP status, these are stream errors too, with `status()` 0: ``` unpulled: com.kosa8.Kosa8Exception$StreamException status=0: image "docker.io/library/python:3.12-alpine" not found in engine: image "docker.io/library/python:3.12-alpine": not found exec missing: com.kosa8.Kosa8Exception$StreamException status=0: no such container: nope ``` **Handle:** `e.stdout()` and `e.stderr()` hold whatever was printed before the failure; `e.daemonMessage()` is the error frame's text. A non-zero exit code is *not* a `StreamException`. `execFrames` does not throw one for an error frame either: it hands the frame to your consumer. ### `CheckpointException` **When:** client-side checkpoint bookkeeping fails, before or after the daemon is involved: ``` rewind none: Kosa8Exception$CheckpointException status=0 method=null path=null message: no checkpoints for "never-checkpointed" — take one with checkpoint() or `kosa8 checkpoint never-checkpointed` bad name: Kosa8Exception$CheckpointException status=0 method=null path=null message: invalid sandbox name "../etc" ``` Also: `no checkpoint "X" for sandbox "Y"` when `rewind(to)` matches no id or label; `update the checkpoint store …` when the file cannot be written (the `IOException` is the cause); a lock that another process holds for longer than the store's lock timeout (10 seconds unless you built the store with `new FileCheckpointStore(path, Duration)`), here with a 2-second limit: ``` com.kosa8.Kosa8Exception$CheckpointException after 2021ms: timed out after 2s waiting for /private/tmp/…/ck/checkpoints.json.lock; another kosa8 process is holding it ``` and, most importantly, a failed restore during a rewind: **A rewind whose restore failed.** A rewind deletes the sandbox first, then restores the checkpoint under the same name. If the restore fails, the sandbox no longer exists, and the message says so and tells you how to bring it back. To produce one for this page, the latest checkpoint's snapshot was deleted behind the SDK's back before rewinding to it: ``` failed rewind: com.kosa8.Kosa8Exception$CheckpointException: restore failed and doc-java-011 no longer exists (it was deleted to free its name for the restore): no such snapshot: doc-java-011-ckpt-2 (list snapshots with `kosa8 snapshot ls`) — the checkpoint is intact; recover with restoreSnapshot("doc-java-011-ckpt-2", new Options.Restore().name("doc-java-011")) sandbox after failed rewind: null ``` "The checkpoint is intact" is true when the restore failed for a passing reason such as a locked screen. In this artificial case the snapshot itself was gone, so there was nothing to restore. (0.1.0 said the sandbox "is now stopped", which was wrong: it has been deleted.) On a Mac the usual cause is a locked screen: the daemon answers `macOS blocks restoring a VM while the screen is locked; unlock and retry` (as seen from the Go SDK against the same daemon). Unlock, then run the call from the message: ``` k.restoreSnapshot("doc-java-demo-ckpt-1", new Options.Restore().name("doc-java-demo")); ``` The checkpoint records are not changed by a failed rewind, so `rewind` also works again once the cause is fixed. `getCause()` is the original `ApiException`. **If you give up instead of recovering**, clean up by hand. With the sandbox gone, `delete()` throws `NotFoundException` before it reaches the checkpoint bookkeeping. The sandbox's records stay in `~/.kosa8/checkpoints.json` and their snapshots stay on disk (seen). Delete the snapshots with `deleteSnapshot(id, null)` for each `sb.checkpoints()` entry, then drop the records: ``` new FileCheckpointStore().update(d -> d.remove("doc-java-011")); ``` ### `TimeoutException` ``` timeout: com.kosa8.Kosa8Exception$TimeoutException: POST /kosa8/v1/sandboxes/doc-java-guide/run timed out talking to unix:///Users/dhruvit/.kosa8/run/kosa8.sock ``` **When:** `Options.Run.timeout` or the client-wide `Builder.timeout` passed. **Handle:** the command in the sandbox keeps running: after this exception the `sleep 30` container still showed `status=running`. Clean it up yourself. The exception carries no output; use the `onStdout`/`onStderr` callbacks to keep partial output. ### `Kosa8Exception` itself **When:** the SDK refused before sending anything, or `explore` could not finish. Real messages: ``` bad actor: actor must be human, agent or tool, not "robot" plain http: refusing to talk to 10.0.0.5:8080 over plain http: the token and every request would cross the network unencrypted. Use https://10.0.0.5:8080 (a worker behind a TLS-terminating tunnel or proxy), or set insecure / KOSA8_INSECURE=1 if this link is already private and trusted bad scheme: KOSA8_HOST scheme "ftp" is not supported; use http://, https://, tcp:// or unix:// egress: egressAllow already limits the network to the hosts it lists; do not also set egress explore one: give at least two variants; with one there is nothing to compare capture(true): capture(true) is not enough: the daemon needs a host file to write the capture to; use capture("/path/to/capture.pcapng") capture(""): capture needs a host file path to write the capture to bad mount: mount "/tmp": want host:guest or host:guest:ro relative: mount "mnt:/work": both paths must be absolute (use Path.toAbsolutePath() for the host side) exec env: exec does not take env: the container keeps the environment it was started with. Set it at startInteractive, or wrap the command: List.of("env", "K=V", "cmd") second stdin: stdin of container k3-d6cc9b51 in doc-java-011 was already sent and is closed: the daemon treats one writeStdin body as the whole of stdin. Pass an InputStream to stream input ``` The first three come from `build()`. The capture, mount, exec and stdin checks replace calls that 0.1.0 sent and that then failed or quietly did nothing. (0.1.1 worded the two capture messages as "host directory"; 0.1.2 says file, which is what the daemon writes.) Others from the source: `KOSA8_HOST "…" names no host`; from `explore`, `forked N branches for M variants; see listSandboxes() for what was created`, `keep X is not one of the branches (…); all of them were kept`, `explore interrupted` (the thread's interrupt flag is set again) and `explore branch failed: …`. ### What can escape that is not a `Kosa8Exception` | Exception | From | When | |---|---|---| | `java.io.UncheckedIOException` | `FileCheckpointStore.load()` | The checkpoint file exists but cannot be read (permissions). A missing file is fine, and an unparseable one reads as empty. | | anything | your own `CheckpointStore`, your callbacks | An unchecked exception thrown by your `onStdout`/`onStderr`/`onStatus` callback or `execFrames` consumer propagates out of the call and the connection is closed. One exception to that: an `IllegalArgumentException` (or subclass, such as `NumberFormatException`) is caught by the frame reader and comes out as `StreamException: bad frame from …`. (From the source.) | | `NullPointerException` | arguments | A `null` sandbox, container or snapshot id (checkpoint calls check the name first and throw `CheckpointException` instead), or a `null` variants list for `explore`. Options arguments may be `null`; ids may not. | ## A handling pattern ``` try { Results.Exec r = sb.run("alpine:latest", List.of("sh", "-c", script), new Options.Run().timeout(Duration.ofMinutes(5))); if (!r.ok()) log.warn("script failed with exit {}: {}", r.exitCode(), r.stderr()); } catch (Kosa8Exception.TimeoutException e) { log.warn("still running after 5 minutes; deleting the sandbox"); sb.delete(); } catch (Kosa8Exception.StreamException e) { log.error("could not run: {} (output so far: {})", e.daemonMessage(), e.stdout()); } catch (Kosa8Exception.ApprovalRequiredException e) { log.info("needs a signed approval: {}", e.approval().get("why")); } catch (Kosa8Exception.ApiException e) { log.error("daemon refused ({}): {}", e.status(), e.getMessage()); } catch (Kosa8Exception.ConnectionException e) { log.error("is the daemon running? {}", e.getMessage()); } ``` Order matters: put subclasses before their parents (`ApprovalRequiredException` before `PolicyException` before `ApiException` before `Kosa8Exception`), or the compiler will reject the later `catch` as unreachable. ## Troubleshooting Search the page for a few words of the message you see. | Symptom | Cause | Fix | |---|---|---| | Maven: `prefixes: Path com/kosa8/kosa8/0.1.0/kosa8-0.1.0.jar NOT allowed from central` (the `.pom` downloads, the jar does not) | Newer Maven resolvers check Central's list of top-level folders (`/.meta/prefixes.txt`) before downloading. Early on 3 November 2026 the list did not yet include the new `com/kosa8` namespace, and Maven 3.10.0 refused the jar. Later that day the list included it, and a fresh local repository resolved 0.1.1 with no flags. You can still see this if your local repository or a mirror kept the old list. | `mvn -Daether.remoteRepositoryFilter.prefixes=false …` once, or add that flag to `.mvn/maven.config`. Once the jar is in your local repository the flag is no longer needed. | | `UnsupportedClassVersionError: com/kosa8/… has been compiled by a more recent version of the Java Runtime (class file version 61.0)` | The jar is built for Java 17 (class file 61) and you are running an older JVM. | Run on Java 17 or later. `java -version` shows which one your build tool uses. | | `ConnectionException: … SocketException: No such file or directory (is kosa8 installed and its daemon running? …)` | The daemon is not running, or the socket is elsewhere. | `kosa8 daemon start`, then `kosa8 daemon status`. If `HOME` differs between your program and the daemon (a service account, a container, a test harness), pass `socketPath(…)`. After a daemon restart every sandbox is stopped; `startSandbox` boots it again. | | `ConnectionException: … SocketException: Unix domain path too long` | macOS limits a socket path to 104 bytes, and the socket is `$HOME/.kosa8/run/kosa8.sock`. Happens with deep temporary `HOME`s in CI. | Use a shorter `HOME`, or a short symlink to the socket's folder, for both the daemon and your program. | | `ApiException: guest not ready on vsock port 1024 after 25s` from `createSandbox` | The new virtual machine did not finish booting in time; seen twice in a row while other programs were booting sandboxes on the same Mac. No sandbox was left behind. | Retry; it succeeded once the machine was less busy. If it persists, `kosa8 doctor` and `~/.kosa8/log/`. | | `StreamException: image "…" not found in engine` | The image was never pulled into this sandbox. `k.pull(ref)` pulls into the host's engine, not the sandbox's. | `sb.pull(ref)` (`pullIntoSandbox`) first. | | `StreamException: no such container: …` | Wrong container id, or it exited. | Check `sb.containers()`. Keep a container alive with a long-running command such as `sleep infinity`. | | `TimeoutException`, and the command is still running in the sandbox | A timeout only closes the connection. | Delete or stop the sandbox, or `exec` a `kill` in the container. | | `createSandbox` (or a restore) throws `TimeoutException` although nothing is wrong | `Builder.timeout` applies to every request, and boots and restores can take tens of seconds on a busy machine (10–31 s were seen while testing). | Leave the client-wide timeout unset and use `Options.Run.timeout` per command, or set it well above your slowest boot. | | `Kosa8Exception: capture(true) is not enough: …` (0.1.0: `ApiException 500: capture: no output path`) | The daemon needs a path to write the capture to; `capture(true)` has none. 0.1.1 refuses it before sending; 0.1.0 sent it and got the 500. | `capture("/some/folder/traffic.pcapng")`: kosa8 0.9.36 writes one pcapng file at that path. On 0.1.0, create the sandbox with `kosa8 sandbox create NAME --pcap FILE` and drive it from Java by name. | | `ApiException 500: no such sandbox` from `startSandbox` | kosa8 0.9.36 answers 500, not 404, for this case. | Catch `ApiException` rather than `NotFoundException` here, or check `getSandbox(name) != null` first. | | `Kosa8Exception: refusing to talk to … over plain http` | A non-loopback `http://` worker. | Use `https://`; or, on a link that is already private, `insecure(true)` / `KOSA8_INSECURE=1`. | | `AuthException` (401) from a worker, although `KOSA8_TOKEN` is set | `KOSA8_TOKEN` is used only when the host came from `KOSA8_HOST`. | Call `token(…)` when you call `host(…)`. | | `OverlappingFileLockException` from `checkpoint`, `rewind` or `deleteSandbox` (0.1.0 only) | Two clients in one JVM, each with its own default checkpoint store, updated the file at once. Fixed in 0.1.1: stores on one path share a lock. | Upgrade to 0.1.1. On 0.1.0, share one `Kosa8Client`, or build each with the same `CheckpointStore` instance. | | `CheckpointException: timed out after 10s waiting for …checkpoints.json.lock` | Another process (often a CLI command, or a stuck program) held the checkpoint file lock for longer than the store's limit. | Find the holder (`lsof ~/.kosa8/checkpoints.json.lock`), or allow longer: `builder().checkpoints(new FileCheckpointStore(path, Duration.ofSeconds(60)))`. | | A checkpoint taken from the CLI is missing from Java's list, or the other way round (Linux) | Java's `fcntl` lock and the CLI's `flock` lock do not exclude each other on Linux, so two updates at the same instant can lose one record. On macOS they do exclude each other (tested). | Do not checkpoint the same machine from the CLI and Java at the same moment on Linux, or give the SDK its own store (`MemoryCheckpointStore`, or `FileCheckpointStore` on another path). | | `CheckpointException: restore failed and X no longer exists …` (0.1.0: "… is now stopped") | The restore inside a rewind failed; on a Mac usually a locked screen. The sandbox has been deleted. | Fix the cause and run the `restoreSnapshot(…)` call in the message. [Details], including clean-up if you give up. | | `CheckpointException: no checkpoint "X" for sandbox "Y"` | No id or label matches. Labels match without regard to case; ids match exactly. | `sb.checkpoints()` or `kosa8 checkpoint ls Y`. | | `Kosa8Exception: egressAllow already limits the network …` | `egressAllow` together with `egress("any")` or another non-`deny` value. | Use one or the other. | | `Kosa8Exception: exec does not take env` | 0.1.1 refuses `exec` with `Options.Run.env`; the daemon's exec has no environment (0.1.0 dropped it silently). | Wrap the command: `List.of("env", "K=V", "cmd")`, or set the environment when starting the container with `startInteractive`. | | `Kosa8Exception: stdin of container … was already sent and is closed` | A second `writeStdin` to the same container from the same client. One body is the whole of stdin; the daemon closes it when the body ends (0.1.0 answered ok and the input was lost). | Send everything in one call, or pass one `InputStream` that yields input over time (a `PipedInputStream`). | | `Kosa8Exception: mount "…": want host:guest or host:guest:ro` | A mount string without exactly a host and a guest path, and optionally `:ro`/`:rw`. | Fix the string. (In 0.1.0, every mount failed at the daemon with `500 mount has no guest path`; upgrade.) | | `Kosa8Exception: mount "…": both paths must be absolute …` | A relative host path (`mnt:/work`) or a guest path without a leading `/`. Checked since 0.1.2. | `Path.of("mnt").toAbsolutePath() + ":/work"`. | | Output callbacks see half lines, or two lines at once | Callbacks receive chunks as the daemon sends them, not lines. | Buffer and split on `\n` yourself. | | stderr appears before stdout that was printed earlier | Order is kept within a stream, not across the two. | If order matters, have the command write both to one stream (`2>&1`). | | Sandboxes pile up after crashes | Sandboxes outlive the JVM, and the SDK has no shutdown hook. | `try`/`finally` with `delete()`, plus a [sweep by label](https://docs.kosa8.com/guides/java-guide/#recipe-sweep). | ## What changed in 0.1.1 and 0.1.2 0.1.1 fixes the problems found while these pages were written and tested. If you are on 0.1.0, these are the differences you will notice when you move to 0.1.1. | Area (0.1.1) | 0.1.0 | 0.1.1 | |---|---|---| | Two clients in one JVM checkpointing | `OverlappingFileLockException` (unchecked, not a `Kosa8Exception`) | They queue on a shared in-process lock | | Waiting for the checkpoint file lock | Forever | 10 s by default (`new FileCheckpointStore(path, Duration)`), then `CheckpointException` | | Two checkpoints of one sandbox at once | Could pick the same id | The loser of the 409 steps to the next id | | `Options.Create.mounts` | Every mount refused by the daemon (500) | Works; bad strings throw before sending | | Traffic capture | `capture(true)` got a 500 | `capture(String path)`; `capture(true)` throws before sending | | `exec` with `env` | Variables silently dropped | `Kosa8Exception` | | Second `writeStdin` to a container | Answered ok; input never seen | `Kosa8Exception` | | Failed rewind message | Said the sandbox "is now stopped" | Says it was deleted and the checkpoint is intact | | `Endpoint.toString()` | Printed the bearer token | `token=***` | | `Sandbox` handle | No stdin, attach or frames | `execFrames`, `startInteractive`, `writeStdin`, `attach` | ### 0.1.2 | Area | 0.1.1 | 0.1.2 | |---|---|---| | `capture(String)` wording | Parameter `hostDir`; javadoc and both errors said "host directory" | Parameter `hostFile`; the javadoc and errors say file, which is what the daemon writes | | Mount paths | Relative paths sent to the daemon | Both paths must be absolute; otherwise `Kosa8Exception` before sending | Unchanged, because they are the daemon's behaviour: a timeout does not stop the command; a missing image or container is a `StreamException`; a missing sandbox on start or snapshot on restore is a 500. **Next** [Java developer guide](https://docs.kosa8.com/guides/java-guide/) for how each feature works, and the [API reference](https://docs.kosa8.com/guides/java-api/) for which methods throw what. For problems with kosa8 itself rather than the SDK, see [When something is wrong](https://docs.kosa8.com/guides/self-repair/). --- Source: https://docs.kosa8.com/guides/java-errors/ # Java API reference Every public type of `com.kosa8:kosa8` 0.1.2, package `com.kosa8`. Signatures are copied from the source, and the list of members on this page was checked against `javap -public` run on the jar from Maven Central. Paths are the daemon's native API; `…` stands for `/kosa8/v1/sandboxes/{sandbox}`. **How this page was tested** The member list was produced with `javap -public` (OpenJDK 21.0.12.1), first on `kosa8-0.1.0.jar` from Maven Central and then on the classes built from the `v0.1.1` tag (commit 6d3f129) and the `v0.1.2` tag (commit 14e5fc6) for the members those releases added; 0.1.2 was built from the `v0.1.2` tag (commit 14e5fc6) with `mvn install`, because Maven Central was still answering 404 for 0.1.2 while it was being published. Every public member it printed is on this page. Members and behaviour new or changed since 0.1.0 are marked with the release that brought them, such as 0.1.1. Behaviour notes come from reading the source of that version and, where marked "seen", from runs against kosa8 daemon 0.9.36 on macOS 26.0.1 (the programs are on the [developer guide](https://docs.kosa8.com/guides/java-guide/) and [errors](https://docs.kosa8.com/guides/java-errors/) pages). ## What this is, in plain words This page is the dictionary: every class and method the Java SDK offers, with exactly what it takes, what it gives back and how it can fail. If you are new to kosa8, start with the [quickstart](https://docs.kosa8.com/guides/java-quickstart/); come here when you need the precise detail of one call. [`Conventions`Types, nulls, approvals, threads.] [`Kosa8Client`The builder and every method with its endpoint.] [`Sandbox`The handle bound to one sandbox.] [`Options`What methods take.] [`Results`What methods return.] [`Checkpoint stores`Where checkpoint stacks live.] [`Endpoint, Frame, Json`Lower-level pieces.] [`Kosa8Exception`Every exception class.] ## Conventions - **Artifact.** `com.kosa8:kosa8:0.1.2` on Maven Central. Class files for Java 17 (major version 61); no dependencies. The jar has no `module-info` and no `Automatic-Module-Name`, so on the module path it is the automatic module `kosa8`. - **Exceptions are unchecked.** Every method may throw a subclass of `Kosa8Exception` (a `RuntimeException`). The column "throws" below lists only what is specific to a method; any call to the daemon can also throw `ConnectionException`, `TimeoutException` and an `ApiException` for an HTTP error. - **JSON values.** Methods with no result record return the daemon's JSON decoded by [`Json`]: objects as `Map` (insertion-ordered), arrays as `List`, whole numbers as `Long`, other numbers as `Double`. An empty answer decodes to `null`, which methods returning a map turn into an empty map. - **Options may be `null`.** Every `Options.*` argument accepts `null`, meaning "none". Sandbox, container and snapshot ids may not be `null`. Ids are URL-encoded into the path. - **Approvals.** Every option class extends [`Options.Call`] and carries an optional signed approval, sent as the `X-Kosa8-Approval` header (trimmed; not sent when blank). Methods whose only option is an approval take `Options.Approval`. - **Transport.** One connection per request, HTTP/1.1 with `Connection: close`, `Accept: application/json` and `X-Kosa8-Actor` on every request. No `User-Agent` is sent. - **Threads.** `Kosa8Client` and `Sandbox` are immutable and safe to share. Option builders are not. See [Thread safety](https://docs.kosa8.com/guides/java-guide/#threads) for the two checkpoint caveats. ## `Kosa8Client` ``` public final class Kosa8Client { public static final String VERSION = "0.1.2"; public static Builder builder(); public Endpoint endpoint(); public Sandbox sandbox(String id); // … the methods in the tables below } ``` There is no public constructor; use `builder()`. `endpoint()` reports where requests go. `sandbox(id)` returns a [`Sandbox`] handle and sends nothing. `VERSION` is the SDK's own version. The class does not implement `AutoCloseable`: it holds no connection. ### `Kosa8Client.Builder` ``` public static final class Builder { public Builder(); public Builder socketPath(String v); public Builder host(String v); public Builder token(String v); public Builder insecure(boolean v); public Builder actor(String v); public Builder agentToken(String v); public Builder headers(Map v); public Builder timeout(Duration v); public Builder checkpoints(CheckpointStore v); public Builder getenv(Function v); public Kosa8Client build(); } ``` | Method | Default | Effect | |---|---|---| | `socketPath` | `Endpoint.defaultSocketPath()` | The daemon's unix socket. Used only when no host is set here or in `KOSA8_HOST`. | | `host` | `$KOSA8_HOST` | A worker. Forms and rules under [`Endpoint.resolve`]. An explicit value wins over the variable. | | `token` | `$KOSA8_TOKEN`, only when the host came from `KOSA8_HOST` | Sent as `Authorization: Bearer …` to a network endpoint only, never over a unix socket. An empty string means none. | | `insecure` | false (or `KOSA8_INSECURE=1`) | Allows plain `http://` to a non-loopback host. | | `actor` | `"tool"` | `human`, `agent` or `tool`, sent as `X-Kosa8-Actor`. Anything else: `build()` throws `Kosa8Exception`. | | `agentToken` | `$KOSA8_AGENT_TOKEN` | Sent as `X-Kosa8-Agent-Token` when non-empty. | | `headers` | none | Extra header lines on every request, written after `X-Kosa8-Actor` and before `X-Kosa8-Approval`, `X-Kosa8-Agent-Token`, `Authorization` and `Content-Type`. Added, never substituted. | | `timeout` | none | Limit for every request without its own. Zero means none. Fires as `TimeoutException` by closing the connection. | | `checkpoints` | `new FileCheckpointStore()` (a new instance per `build()`) | The checkpoint store. See [Checkpoint stores]. | | `getenv` | `System::getenv` | Where `KOSA8_HOST`, `KOSA8_TOKEN`, `KOSA8_INSECURE` and `KOSA8_AGENT_TOKEN` are read. | | `build` | | Resolves the endpoint, checks the actor, picks the store. Sends nothing. Throws `Kosa8Exception` for a refused host or actor. | ### Daemon | Method | Returns | HTTP | |---|---|---| | `Map health()` | Keys `status`, `version`, `commit`, `go`, `uptime_seconds`, `phase` (seen) | `GET /kosa8/v1/health` | | `String version()` | `String.valueOf(health().get("version"))`; `"null"` if absent | `GET /kosa8/v1/health` | | `Map pull(String ref)` `Map pull(String ref, Options.Approval o)` | The daemon's answer | `POST /kosa8/v1/pull` `{"ref"}`: into the host's shared engine, not a sandbox | ### Sandboxes | Method | Returns | HTTP and notes | |---|---|---| | `List> listSandboxes()` | Every sandbox: `id`, `name`, `created`, `state`, `cpus`, `mem_mb`, `disk_gb`, `egress`, `ip`, and `parent`, `labels` when set (seen) | `GET /kosa8/v1/sandboxes` | | `Map getSandbox(String idOrName)` | The first sandbox whose `id`, then `name`, equals the argument; `null` when none does | `GET /kosa8/v1/sandboxes`, filtered in the client | | `Map createSandbox(Options.Create o)` | Keys `sandbox` (an object), `boot_ms` (seen) | `POST /kosa8/v1/sandboxes` with [the body]. Blocks until booted. Throws `Kosa8Exception` before sending if `egressAllow` is combined with an `egress` other than `deny`; `ConflictException` for a name in use | | `Map startSandbox(String sandbox)` `… startSandbox(String sandbox, Options.Approval o)` | Keys `sandbox`, `boot_ms` (seen) | `POST …/up`. A missing sandbox is a 500 `ApiException` on 0.9.36 | | `Map stopSandbox(String sandbox)` `… stopSandbox(String sandbox, Options.Approval o)` | Keys `ok`, `sandbox`, `warning` (seen) | `POST …/down`. The disk is kept | | `Map deleteSandbox(String sandbox)` `… deleteSandbox(String sandbox, Options.Delete o)` | The daemon's answer (`ok`) plus `checkpoints_dropped` (an `Integer`) | `DELETE …` (`?wipe=1` when `wipe`). If that succeeds and not `keepCheckpoints`: removes the sandbox's stack from the store and sends `DELETE /kosa8/v1/snapshots/{id}` for each record (with the same `wipe` and approval), ignoring `Kosa8Exception`s from either step | | `Map pullIntoSandbox(String sandbox, String ref)` `… pullIntoSandbox(String sandbox, String ref, Options.Approval o)` | Keys `ok`, `image` (the full reference, seen) | `POST …/pull` `{"ref"}`. The host fetches the image and copies it in, so it works with egress denied | | `List sandboxImages(String sandbox)` | Image references, as `docker.io/library/alpine:latest` | `GET …/images` | | `List> sandboxContainers(String sandbox)` | `id`, `image`, `status`, `ip` (seen) | `GET …/containers` | | `Map sandboxEgress(String sandbox)` | `policy` and `stats` (`passed`, `denied`, `dropped`, `dns_answered`, `dns_refused`; seen) | `GET …/egress` | ### Running code | Method | Returns | HTTP and notes | |---|---|---| | `Results.Exec run(String sandbox, String image, List args)` `Results.Exec run(String sandbox, String image, List args, Options.Run o)` | The collected result when the container exits | `POST …/run` `{"image","args","env"?}`; NDJSON frames. A fresh container from an image already in the sandbox. `args` `null` is sent as `[]` | | `String startContainer(String sandbox, String image, List args)` `… startContainer(…, Options.Approval o)` | The container id (`String.valueOf` of the answer's `id`) | `POST …/start` `{"image","args"}`. Returns once started | | `Results.Exec exec(String sandbox, String container, List args)` `Results.Exec exec(…, Options.Run o)` | The collected result | `POST …/exec` `{"container","args"}`; NDJSON. A non-empty `o.env` throws `Kosa8Exception("exec does not take env: …")` before sending 0.1.1 (0.1.0 dropped it silently) | | `void execFrames(String sandbox, String container, List args, Consumer fn, Options.Approval o)` | Nothing; `fn` is called for every frame, including the final `done` frame | `POST …/exec`. No timeout; the client-wide timeout applies. An error frame is passed to `fn`, not thrown. `StreamException` only if the stream breaks or a line is not a frame | | `String startInteractive(String sandbox, String image, List args, Options.Interactive o)` | The container id | `POST …/start-interactive` `{"image","args","env","tty"}` (`env` is sent as `null` when unset) | | `Map writeStdin(String sandbox, String container, byte[] data, Options.Approval o)` | `{ok=true}` (seen) | `POST …/containers/{cid}/stdin`, `application/octet-stream` with `Content-Length`. The container's stdin is closed when the body ends (seen). Once per container per client: a second call for the same sandbox and container throws `Kosa8Exception("stdin of container … was already sent and is closed: …")` before sending (seen) 0.1.1 | | `Map writeStdin(String sandbox, String container, InputStream data, Options.Approval o)` | The daemon's answer | The same, sent with `Transfer-Encoding: chunked` in reads of up to 16 KiB, flushed after each, until `read` returns 0 or less. The stream is not closed by the SDK. The same once-per-container check applies; to feed input over time, pass a stream that yields as you go (a `PipedInputStream`) | | `Results.Exec attach(String sandbox, String container, Options.Run o)` | The collected result when the container exits | `POST …/containers/{cid}/attach`; NDJSON | **How `run`, `exec` and `attach` collect.** Frames with `stream` `stderr` go to `stderrBytes` and `onStderr`; `status` frames go only to `onStatus`; any other stream, or none, goes to `stdoutBytes` and `onStdout`. Frames with no data are skipped. The first `done` frame ends collection: with `error` set it throws `StreamException` (with the output so far), otherwise it yields `Exec(exit, stdout, stderr)`, with exit 0 if the frame had none. A stream that ends with no `done` frame throws `StreamException("stream ended unexpectedly (no final frame)")`; a broken stream throws `StreamException("stream broke: …")` with the output so far; a time limit throws `TimeoutException`, without output. A non-zero exit is not an exception. The daemon also reports an unpulled image or an unknown container this way (seen). ### Snapshots, fork and explore | Method | Returns | HTTP and notes | |---|---|---| | `Map snapshot(String sandbox, Options.Snapshot o)` | Keys `snapshot` (an object: `id`, `sandbox`, `created`, `size_bytes`, paths, …) and `snapshot_ms` (seen) | `POST …/snapshot` `{"id"?, "encrypt"?: true}` | | `List> listSnapshots()` | Every snapshot, checkpoints included | `GET /kosa8/v1/snapshots` | | `Map restoreSnapshot(String snapshotId, Options.Restore o)` | Keys `sandbox`, `restore_ms` (seen) | `POST /kosa8/v1/snapshots/{id}/restore` `{"name"?, "label"?, "allow_tampered"?: true}`. A missing snapshot is a 500 `ApiException` on 0.9.36 | | `Map deleteSnapshot(String snapshotId, Options.SnapshotDelete o)` | Keys `ok`, `removed`, `encrypted` (seen) | `DELETE /kosa8/v1/snapshots/{id}` (`?wipe=1`, `&force=1`) | | `Results.Fork fork(String sandbox, int n, Options.Fork o)` | [`Results.Fork`] | `POST …/fork` `{"n", "prefix"?, "label"?}`; `n` below 1 is sent as 1 | | `Results.Explore explore(String sandbox, List variants, Options.Explore o)` | [`Results.Explore`] | Client-side: optional pull, one fork per variant, `run(branch, image, ["sh","-c",variant])` on each in parallel, keep the fastest exit-0 branch, `DELETE` the rest. Throws `Kosa8Exception` for fewer than two variants, fewer forks than variants, or a `keep` name that is not a branch (all branches kept). Full algorithm in the [developer guide](https://docs.kosa8.com/guides/java-guide/#fork) | ### Checkpoints | Method | Returns | What it does | |---|---|---| | `Results.CheckpointSaved checkpoint(String sandbox, String label)` `… checkpoint(String sandbox, String label, Options.Approval o)` | [`CheckpointSaved`] | Validates the name; picks `-ckpt-`; `POST …/snapshot` `{"id"}`, and on a 409 whose message contains `already exists` (another checkpoint took that id at the same moment) steps to `n+1` and retries, up to 10 attempts; any other 409 is thrown 0.1.1 (seen: two clients at once got `ckpt-1` and `ckpt-2`); if the stack already had 10, deletes the oldest snapshots (best effort); records the checkpoint (label `null` is stored as `""`). Throws `CheckpointException` for an invalid name, before any request | | `List listCheckpoints(String sandbox)` | The stack, oldest first (sorted by `created`); empty if none | Reads the store only; no request | | `Results.Rewind rewind(String sandbox, String to)` `… rewind(String sandbox, String to, Options.Approval o)` | [`Rewind`] | Resolves `to` (an id, or a label without regard to case, newest first; `null` = the latest); `DELETE …`; `POST /kosa8/v1/snapshots/{id}/restore` `{"name": sandbox}`; pops later checkpoints from the store and deletes their snapshots (best effort). Throws `CheckpointException` when there is no such checkpoint, and when the restore fails: the message says the sandbox no longer exists and the checkpoint is intact, and names the recovery call 0.1.1 (seen); the cause is the restore's exception. The store is not changed. A refused `DELETE` is thrown as is, with nothing changed | ## `Sandbox` A handle bound to one sandbox id, from `client.sandbox(id)`. No public constructor. Every method delegates to the client method in the right-hand column with the handle's id. An approval travels in the options object where a method takes one; `start`, `stop`, `pull`, `startContainer`, `checkpoint` and `rewind` take none, so use the client's overloads for gated calls of those. | Method | Same as | |---|---| | `String id()` | the id given to `sandbox(id)` | | `Map info()` | `getSandbox(id)`; `null` when it does not exist | | `Map start()` | `startSandbox(id)` | | `Map stop()` | `stopSandbox(id)` | | `Map delete()` `Map delete(Options.Delete o)` | `deleteSandbox(id)` / `deleteSandbox(id, o)` | | `Map pull(String ref)` | `pullIntoSandbox(id, ref)`, *not* the client's `pull` | | `Results.Exec run(String image, List args)` `Results.Exec run(String image, List args, Options.Run o)` | `run(id, …)` | | `String startContainer(String image, List args)` | `startContainer(id, image, args)` | | `Results.Exec exec(String container, List args, Options.Run o)` | `exec(id, container, args, o)`; there is no two-argument form, pass `null` | | `void execFrames(String container, List args, Consumer fn, Options.Approval o)` 0.1.1 | `execFrames(id, …)` | | `String startInteractive(String image, List args, Options.Interactive o)` 0.1.1 | `startInteractive(id, …)` | | `Map writeStdin(String container, InputStream data, Options.Approval o)` `Map writeStdin(String container, byte[] data, Options.Approval o)` 0.1.1 | `writeStdin(id, …)` | | `Results.Exec attach(String container, Options.Run o)` 0.1.1 | `attach(id, container, o)` | | `List> containers()` | `sandboxContainers(id)` | | `List images()` | `sandboxImages(id)` | | `Map egress()` | `sandboxEgress(id)` | | `Map snapshot(Options.Snapshot o)` | `snapshot(id, o)` | | `Results.Fork fork(int n, Options.Fork o)` | `fork(id, n, o)` | | `Results.Explore explore(List variants, Options.Explore o)` | `explore(id, variants, o)` | | `Results.CheckpointSaved checkpoint(String label)` | `checkpoint(id, label)` | | `List checkpoints()` | `listCheckpoints(id)` | | `Results.Rewind rewind(String to)` | `rewind(id, to)` | | `String toString()` | `"Sandbox(" + id + ")"` | Not on the handle: `restoreSnapshot`, `deleteSnapshot`, and the client overloads that take an `Options.Approval` for `start`, `stop`, `pull`, `startContainer`, `checkpoint` and `rewind`. (0.1.0's handle also lacked `execFrames`, `startInteractive`, `writeStdin` and `attach`.) ## `Options` A holder class (no instances) for the per-call option builders. Each is a public class with a public no-argument constructor; every setter returns `this`. Fields are package-private, so you cannot read back what you set. Unset fields are not sent. ### `Options.Call` and `Options.Approval` ``` public static class Call> { public T approval(String a); // X-Kosa8-Approval } public static final class Approval extends Call { public static Approval of(String sig); } ``` Every option class below extends `Call`, so all of them have `approval(…)`. ### `Options.Create` | Setter | Body field | Notes | |---|---|---| | `name(String)` | `name` | Sandbox name and id. | | `cpus(int)` | `cpus` | Daemon default seen: 2. | | `memMb(int)` | `mem_mb` | Daemon default seen: 1024. | | `diskGb(int)` | `disk_gb` | Daemon default seen: 32. | | `mounts(List)` | `mounts`: a list of `{"host_path","guest_path","read_only"}` objects | Each string `host:guest`, `host:guest:ro` or `host:guest:rw`; any other shape (wrong number of colons, an empty part) throws `Kosa8Exception("mount \"…\": want host:guest or host:guest:ro")` when the request is built. A host path that is not absolute (`Path.of(host).isAbsolute()`) or a guest path not starting with `/` throws `Kosa8Exception("mount \"…\": both paths must be absolute (use Path.toAbsolutePath() for the host side)")` 0.1.2 (seen). Visible inside containers (seen: a host file read at `/work`). 0.1.1: 0.1.0 sent the strings and every mount failed with `500 mount has no guest path`. | | `egress(String)` | `egress` | `deny` \| `any` \| `fakenet` \| `allow:host,host`. | | `egressAllow(List)` | `egress` = `allow:` + the hosts, trimmed, comma-joined | Ignored when empty. With `egress` set to anything but `deny`: `Kosa8Exception` at send time. | | `capture(String hostFile)` 0.1.1 | `capture`: `{"path": hostFile}` | Record the sandbox's traffic to this host file as pcapng; kosa8 0.9.36 writes one file there, mode 0600 (seen). The folder must exist. Empty string: `Kosa8Exception("capture needs a host file path to write the capture to")` (seen). (0.1.1 named the parameter `hostDir` and said "directory"; corrected in 0.1.2.) | | `@Deprecated capture(boolean)` | nothing | `capture(true)` throws `Kosa8Exception("capture(true) is not enough: the daemon needs a host file to write the capture to; use capture(\"/path/to/capture.pcapng\")")` when the request is built (seen); `capture(false)` does nothing. A later `capture(String)` takes precedence. | | `labels(Map)` | `labels` | Free-form tags, returned by `listSandboxes`. | | `broker(Object)` | `broker` | Written with [`Json.write`]: pass a `Map`/`List` structure. Any other object is written as its `toString()` string. | ### `Options.Delete` | Setter | Default | Effect | |---|---|---| | `wipe(boolean)` | false | `?wipe=1`: overwrite the disks, not just unlink. Also passed to the checkpoint snapshots' deletes. | | `keepCheckpoints(boolean)` | false | Leave the sandbox's checkpoint records and snapshots in place. | ### `Options.Run` For `run`, `exec` and `attach`. | Setter | Default | Effect | |---|---|---| | `env(List)` | none | `KEY=VALUE` strings. Sent by `run` only. | | `env(Map)` | none | Converted to `KEY=VALUE`, in the map's iteration order. Replaces any earlier `env`. | | `onStdout(Consumer)` | none | Called with each stdout chunk as it arrives, on the calling thread. Chunks are not lines. | | `onStderr(Consumer)` | none | The same for stderr. | | `onStatus(Consumer)` | none | Frames on the `status` stream (pull and progress lines). Not collected into the result. | | `timeout(Duration)` | the client's | Time limit for this call. Ends the request, not the process. | ### `Options.Interactive` | Setter | Default | Effect | |---|---|---| | `env(List)` | `null` | `KEY=VALUE` for the container. | | `tty(boolean)` | false | Allocate a terminal. | ### `Options.Snapshot`, `Options.Restore`, `Options.SnapshotDelete`, `Options.Fork` | Class | Setter | Sent as | Effect | |---|---|---|---| | `Snapshot` | `id(String)` | `id` | The snapshot id; the daemon picks one if unset. | | `encrypt(boolean)` | `encrypt: true` | Encrypt at rest once taken. | | | `Restore` | `name(String)` | `name` | Name of the restored sandbox. | | `label(String)` | `label` | Raise the classification above the snapshot's (never lower). | | | `allowTampered(boolean)` | `allow_tampered: true` | Restore a sealed snapshot whose seal does not verify; audited. | | | `SnapshotDelete` | `wipe(boolean)` | `?wipe=1` | Overwrite the files. | | `force(boolean)` | `?force=1` | Passed through to the daemon. | | | `Fork` | `prefix(String)` | `prefix` | Forks are named `prefix1`, `prefix2`, … (seen). | | `label(String)` | `label` | Raise the forks' classification above the parent's (never lower). | | ### `Options.Explore` | Setter | Default | Effect | |---|---|---| | `image(String)` | `"alpine:latest"` | The image each variant runs in, as `sh -c VARIANT`. | | `pull(boolean)` | true | Pull `image` into the sandbox first. Set false when it is already there. | | `timeout(Duration)` | 120 seconds | Per branch. | | `prefix(String)` | `-explore-` | Branch name prefix. | | `keep(String)` | none | Keep this branch instead of the fastest success. | | `keepAll(boolean)` | false | Delete no branch. | | `approval(String)` | none | Used for the pull, the fork, every run and every delete. | ## `Results` A holder class for the result records. Records are immutable, but `Exec`'s byte arrays are returned as is, and record equality compares arrays by reference, so two `Exec`s with the same output are not `equals`. ``` public record Exec(int exitCode, byte[] stdoutBytes, byte[] stderrBytes) { public String stdout(); // UTF-8 public String stderr(); // UTF-8 public boolean ok(); // exitCode == 0 } public record Fork(List> forks, Object snapshot, long forkMs, int count) {} public record ExploreBranch(String name, String variant, int exit, String output, String error, long durationMs) {} public record Explore(String parent, List branches, String winner, int removed) {} public record CheckpointSaved(Checkpoint checkpoint, List evicted, Map snapshot) {} public record Rewind(Object sandbox, Checkpoint checkpoint, List dropped, Object restoreMs) {} ``` | Record component | Meaning | |---|---| | `Fork.forks` | The new sandboxes, as `listSandboxes` shows them. | | `Fork.snapshot` | The daemon's `snapshot` value: the id of the snapshot the forks came from (a `String`, seen). | | `Fork.forkMs`, `Fork.count` | The daemon's `fork_ms` and `count`; 0 when absent. | | `ExploreBranch.exit` | The variant's exit code; -1 if it never ran. | | `ExploreBranch.output` | stdout then stderr, trailing newlines removed. | | `ExploreBranch.error` | The exception message if the run failed; `null` otherwise. | | `ExploreBranch.durationMs` | Wall time of the branch, retries included. | | `Explore.winner` | The kept branch's name; `""` when no branch succeeded (and then none is kept, unless `keepAll`). | | `Explore.removed` | Branches deleted successfully. | | `CheckpointSaved.evicted` | Ids of old checkpoints removed to stay at 10. | | `CheckpointSaved.snapshot` | The daemon's answer to the snapshot: `snapshot` (object) and `snapshot_ms`. | | `Rewind.sandbox` | The restored sandbox's map (from the restore answer). | | `Rewind.dropped` | Ids of checkpoints after the target, now gone. | | `Rewind.restoreMs` | The daemon's `restore_ms` (a `Long`). | ### `Checkpoint` ``` public record Checkpoint(String id, String sandbox, String label, boolean auto, Instant created) {} ``` `id` is `-ckpt-`. `label` is never `null` (`""` when none). `auto` is false for every checkpoint the SDK takes. `created` is `Instant.EPOCH` if the stored timestamp cannot be parsed. ## Checkpoint stores ``` public interface CheckpointStore { Map> load(); // a copy of the stacks T update(Function>, T> fn); // run fn on a mutable copy, save, serialised } public final class FileCheckpointStore implements CheckpointStore { public FileCheckpointStore(); // ~/.kosa8/checkpoints.json (from user.home) public FileCheckpointStore(Path path); // lock timeout 10 s public FileCheckpointStore(Path path, Duration lockTimeout); // 0.1.1 public Path path(); public Map> load(); public T update(Function>, T> fn); } public final class MemoryCheckpointStore implements CheckpointStore { public MemoryCheckpointStore(); public synchronized Map> load(); public synchronized T update(Function>, T> fn); } ``` | Store | Behaviour | |---|---| | `FileCheckpointStore.load` | Reads the file. Missing file: empty map. Unparseable file: empty map. Other read errors: `UncheckedIOException`. Records are `{"id","sandbox","label","auto"?,"created"}`; `created` is parsed with `OffsetDateTime`, which accepts the CLI's local offsets and nanoseconds. | | `FileCheckpointStore.update` | Takes a JVM-wide `ReentrantLock` shared by every store on the same absolute, normalised path 0.1.1; creates the parent folder; takes `.lock` with `FileChannel.tryLock()` (an `fcntl` lock on Unix), polling every 50 ms; loads; runs `fn`; writes `.tmp`, sets it to 0600 where POSIX permissions exist, and renames it over the file atomically (falling back to a plain replace if the filesystem cannot). Both locks together wait at most `lockTimeout` (default 10 s), then throw `CheckpointException("timed out after Ns waiting for …lock; another kosa8 process is holding it")` (seen); an interrupt while waiting throws `CheckpointException` and sets the interrupt flag again. `auto` is omitted when false; `created` is written as an ISO-8601 UTC instant. An `IOException` becomes `CheckpointException("update the checkpoint store …")`. Two stores on one file in one JVM now queue (seen: the second waited 1027 ms, no exception); in 0.1.0 they threw `OverlappingFileLockException`. The CLI's `flock` excludes this lock on macOS (seen) but not on Linux. | | `MemoryCheckpointStore` | A `HashMap` behind the instance's monitor. `load` and `update` work on copies, and `update` saves the copy only after `fn` returns. Nothing is shared with the CLI or other instances. | A custom store must return copies from `load` (the SDK does not modify them) and make `update` atomic with respect to other updates. If `fn` throws, the store should not save; both built-in stores propagate the exception without writing. ## Lower-level pieces ### `Endpoint` ``` public record Endpoint(String socketPath, String scheme, String host, int port, String basePath, String token) { public boolean isUnix(); // socketPath != null public String target(); // "unix://" + socketPath, or scheme://host:port (no token) public static String defaultSocketPath(); // $user.home/.kosa8/run/kosa8.sock public static Endpoint resolve(String host, String socketPath, String token, boolean insecure, Function getenv); } ``` `resolve` applies the CLI's `KOSA8_HOST` rules (what `build()` calls): 1. No `host` and an empty `KOSA8_HOST`: the unix socket `socketPath`, or the default. 2. The value is trimmed and trailing slashes removed; the scheme is lower-cased. 3. `unix:///path`: that socket. `tcp://` is treated as `http://`. No scheme: `http://`. Any other scheme: `Kosa8Exception`. 4. No host in the URL: `Kosa8Exception("KOSA8_HOST \"…\" names no host")`. Default port 443 for https, 80 for http. 5. Plain http to a host other than `localhost`, `::1` or `127.*`, without `insecure` or `KOSA8_INSECURE=1`: `Kosa8Exception`. 6. `token`: the argument, else `KOSA8_TOKEN` only when the host came from the environment; an empty token is `null`. 7. `basePath`: the URL's path without trailing slashes, prefixed to every request. `getenv` `null` means `System::getenv`. `toString()` is overridden and never prints the token 0.1.1: it gives `Endpoint[unix:///path]` or `Endpoint[scheme://host:port/base, token=***]` (seen: `Endpoint[https://worker.example.com:443/k8, token=***]`). `token()` still returns it. ### `Frame` ``` public record Frame(String stream, byte[] data, boolean done, boolean ok, String error, Integer exit, String id) {} ``` One NDJSON line of a run, exec or attach stream. `data` is base64-decoded (empty array when absent); empty strings in `stream`, `error` and `id` become `null`; `exit` is `null` except on a final frame that carries one. On the final frame (`done` true), `ok` says the stream ended cleanly, not that the command succeeded (seen: `ok=true exit=4`). Frames reach you only through `execFrames`. ### `Json` ``` public final class Json { public static Object parse(String s); // throws IllegalArgumentException("bad JSON: …") on bad input public static String write(Object v); } ``` `parse`: objects to `LinkedHashMap`, arrays to `ArrayList`, integers to `Long` (to `Double` if too big for a long), numbers with a fraction or exponent to `Double`, plus `String`, `Boolean` and `null`. Trailing data is an error. `write`: `Map` (keys via `String.valueOf`), `Iterable`, `Object[]`, `String`, `Boolean`, `Integer`/`Long`/`Short` as is, other `Number`s through `double` (whole values under 1015 without a fraction), `null`; anything else as the JSON string of its `toString()`. Non-ASCII is written as is (UTF-8). ## `Kosa8Exception` ``` public class Kosa8Exception extends RuntimeException { public Kosa8Exception(String message); public Kosa8Exception(String message, Throwable cause); public Kosa8Exception(String message, int status, String daemonMessage, String remediation, String method, String path, Object body, Throwable cause); public int status(); // HTTP status, or 0 public String daemonMessage(); // the daemon's "error" text, verbatim public String remediation(); // the daemon's "remediation" public String method(); public String path(); public Object body(); // decoded response body } ``` | Nested class | Extends | Thrown for | Extra | |---|---|---|---| | `ConnectionException(String m, String method, String path, Throwable cause)` | `Kosa8Exception` | Daemon unreachable; any I/O or parse failure while connecting or reading the status line and headers; an I/O failure reading the body of a JSON answer. | `getCause()` | | `ApiException(String m, int st, String dm, String rem, String method, String path, Object body)` | `Kosa8Exception` | HTTP status ≥ 400 with no subclass below (e.g. 500). | | | `BadRequestException(…)` | `ApiException` | 400 | | | `AuthException(…)` | `ApiException` | 401 | | | `PolicyException(…)` | `ApiException` | 403 | | | `ApprovalRequiredException(…, Map approval)` | `PolicyException` | 403 whose body has an `approval` object with a string `why`. | `Map approval()` | | `NotFoundException(…)` | `ApiException` | 404 | | | `ConflictException(…)` | `ApiException` | 409 | | | `UnsupportedException(…)` | `ApiException` | 501 | | | `StreamException(String m, String stdout, String stderr, String daemonMessage)` | `Kosa8Exception` | Error frame, broken stream, bad frame, or no final frame in `run`/`exec`/`attach`. | `String stdout()`, `String stderr()` | | `CheckpointException(String m)`, `CheckpointException(String m, Throwable cause)` | `Kosa8Exception` | Invalid name, no checkpoint, store write failure, failed restore inside a rewind. | | | `TimeoutException(String m, String method, String path)` | `Kosa8Exception` | A request's time limit passed. | | For an HTTP error the message is the body's `error` (or `message`), with ` (remediation)` appended when the body has one; a non-JSON body is used as the message, trimmed; an empty one gives `"request failed with HTTP "`. Real messages and handling advice are on [Java errors and troubleshooting](https://docs.kosa8.com/guides/java-errors/). **See also** [Quickstart](https://docs.kosa8.com/guides/java-quickstart/) · [Developer guide](https://docs.kosa8.com/guides/java-guide/) · [Errors and troubleshooting](https://docs.kosa8.com/guides/java-errors/) · [Native HTTP API](https://docs.kosa8.com/guides/native-api/), which every method here wraps. --- Source: https://docs.kosa8.com/guides/java-api/ # JavaScript and TypeScript: choosing a path There are four ways to use kosa8 from a Node program. Two are npm packages built for it, and two are standard protocols kosa8 speaks. They all reach the same daemon on your machine, so you can mix them. This page explains what each one is for and helps you pick. **How these pages were tested** Every code block in this section was run against a real kosa8 v0.9.10 daemon on an Apple silicon Mac, with the published packages `@kosa8/sdk@0.1.0` and `@kosa8/ai-sdk-sandbox@0.1.0` on Node.js 25.9.0. The output under each block is what that run printed. The daemon ran in a separate state directory with `HOME=/tmp/k8d`, so paths in the output read `/tmp/k8d/…` where yours will show your home folder. Timings are from that run and will differ on your machine. A few things could not be run, such as a GitHub Actions job and a remote worker on another machine. Those are marked untested where they appear. ## The four paths **@kosa8/sdk**A typed client for kosa8's native API, for Node 18 and later. It covers sandboxes, run and exec, network rules, the credential broker, checkpoints and rewind, fork, explore, snapshots and signed approvals. It has no runtime dependencies. Use it when your own code decides what runs: a backend that runs user code, a test harness, a CI step, or your own agent loop. **@kosa8/ai-sdk-sandbox**A kosa8 sandbox for the Vercel AI SDK 7, for Node 22 and later. It implements the AI SDK's sandbox interfaces, so tools get an `experimental_sandbox` to read and write files and run commands in, and `HarnessAgent` can use it as its sandbox. Use it when the model decides what runs and you already build on the AI SDK. **MCP**`kosa8 mcp serve` exposes 30 kosa8 tools over the Model Context Protocol, on stdio or HTTP. Any MCP client can use them: the AI SDK's MCP client, Mastra, LangChain.js, Claude Code and others. The model drives kosa8 directly. Use it when you want the agent to create and manage its own sandboxes, inside limits you set on the command line. **Docker API**The daemon also serves the Docker Engine API on the same socket. dockerode, testcontainers-node and anything else built on them work after you set `DOCKER_HOST`. Containers run in kosa8's shared engine VM, not in sandboxes of their own. Use it when you already have Docker code and want it off Docker Desktop, unchanged. ## Decision table | You want to… | @kosa8/sdk | AI SDK adapter | MCP | Docker API | |---|---|---|---|---| | Run code in a microVM with its own kernel | yes | yes | yes | no: the shared engine VM | | Close the network, or allow named hosts only | yes, per sandbox | yes, `networkPolicy` | yes, per sandbox | no | | Keep API keys out of the sandbox (broker) | yes | yes, `broker` | no | no | | Checkpoint and rewind a running machine | yes | stop and resume only | yes | no | | Fork, and try N approaches in parallel | yes | no | yes | no | | Give an AI SDK agent file and shell tools | you write the tools | yes, built in | the model calls kosa8's tools | no | | Let the model create its own sandboxes | no | no | yes | no | | Reuse existing dockerode or Testcontainers code | no | no | no | yes | | Bind-mount host folders | yes, `mounts` | yes, `mounts` | below `--allow-mount` | below `daemon start --allow-mount` | | Signed approvals for risky actions | yes, `approval` option | yes, `approval` setting | yes, `approval` argument | yes (the gate is in front of every route) | | Node version | 18+ | 22+ | any | any | ## How they fit together > Diagram: Your Node program uses one of four clients: @kosa8/sdk, the AI SDK adapter built on it, an MCP client talking to kosa8 mcp serve, or dockerode. All four send HTTP to the kosa8d daemon over its unix socket. The daemon runs sandboxes, each a microVM, for the first three, and runs Docker API containers in its shared engine VM. Every path enters at the same daemon, so every path is identified, gated by policy and written to the audit log the same way. ## Packages at a glance | Package | Version | Node | Depends on | Formats | |---|---|---|---|---| | `@kosa8/sdk` | 0.1.0 | 18 or later | nothing at run time | ESM and CommonJS, with `.d.ts` | | `@kosa8/ai-sdk-sandbox` | 0.1.0 | 22 or later | `@kosa8/sdk`, `@ai-sdk/harness`, `@ai-sdk/provider-utils` | ESM and CommonJS, with `.d.ts` | Both are MIT-licensed. The licence covers the packages only, not kosa8 itself. Source: [github.com/kosa8ai/kosa8-js](https://github.com/kosa8ai/kosa8-js). ## What none of them can do yet #### Limits in kosa8 v0.9.10 and the 0.1.0 packages - **A sandbox's network rule is fixed when it is created.** There is no call to change egress on a running sandbox. Create a new one with the rule you want. - **No port forwarding from sandboxes.** The native API cannot expose a sandbox port on the host. (The Docker API's `-p` works, on the shared engine.) - **Files go in and out through exec.** There is no file endpoint for sandboxes. The AI SDK adapter and the helpers on the [Files](https://docs.kosa8.com/guides/js-files/) page use `sh`, `cat` and `base64` inside the container, so the image needs them. Alpine, Debian and the Node images have them. - **No stdin for exec.** A command started with `exec` cannot read stdin. Use `startInteractive` and `writeStdin` for a process that needs input. - **Checkpoints are built from snapshots on the client side.** The daemon has no checkpoint endpoint. The SDK keeps the list in the same file as the CLI, `~/.kosa8/checkpoints.json`, so both see the same checkpoints. - **Remote workers are untested from the SDK.** The SDK accepts `KOSA8_HOST` and `KOSA8_TOKEN` like the CLI. It was tested over the unix socket and over loopback TCP on one machine, not against a worker on another machine. ## Start here [`Install and prerequisites` kosa8, the daemon, Node, and a first health check.](https://docs.kosa8.com/guides/js-install/) [`Quickstart` Your first sandbox from TypeScript in five steps, each with its output.](https://docs.kosa8.com/guides/js-quickstart/) [`Vercel AI SDK adapter` Give an AI SDK agent a sealed machine for files and commands.](https://docs.kosa8.com/guides/js-ai-sdk/) [`Recipes` Complete, tested programs for common jobs.](https://docs.kosa8.com/guides/js-recipes/) --- Source: https://docs.kosa8.com/guides/js-overview/ # Install and prerequisites The npm packages are clients. They do nothing without kosa8 installed and its daemon running on the same machine. This page installs both, checks the connection, and sets up TypeScript. ## What you need | Part | Requirement | |---|---| | Machine | macOS on Apple silicon, or x86-64 Linux with KVM (`/dev/kvm`), `cloud-hypervisor` and `virtiofsd`. Sandboxes are real virtual machines and need a hypervisor kosa8 can drive. | | kosa8 | v0.9.10 or later, with `kosa8d` running as your user. | | Node.js | 18 or later for `@kosa8/sdk`. 22 or later for `@kosa8/ai-sdk-sandbox`, because `@ai-sdk/harness` needs it. | | Disk | About 1.5 GB for the guest image, which the daemon downloads on first use, plus room for sandboxes and snapshots. Disks are sparse and snapshots are copy-on-write clones, but a checkpoint also saves the machine's memory. | | Accounts | None. Nothing in the packages calls a cloud service. | ### Install kosa8 ``` $ curl -fsSL https://kosa8.com/install | sh ``` Homebrew, the Mac app and Linux packages work too; see [Install](https://docs.kosa8.com/guides/install/). Check the version: ``` $ kosa8 version kosa8 client 0.9.10 (ffaa677, darwin/arm64) kosa8 daemon: not running (start with `kosa8 daemon start`) ``` ### Start the daemon ``` $ kosa8 daemon start kosa8d started (pid 39965, log /tmp/k8d/.kosa8/log/kosa8d.log) ``` The daemon listens on `~/.kosa8/run/kosa8.sock`. Only your user can open that socket, and that is its access control. The Mac app starts the daemon for you. The first sandbox you create makes the daemon download the guest image for its release and check it against the published checksum. That first create took 35 seconds in our test; the next one took 1.1 seconds. The daemon log shows the download: ``` 2026/10/15 19:50:12 fetching guest image kosa8-guest-arm64.tar.gz for v0.9.10 2026/10/15 19:50:46 guest image v0.9.10 installed in /tmp/k8d/.kosa8/guest ``` ### Install the package ``` $ npm install @kosa8/sdk # for the Vercel AI SDK, instead or as well: $ npm install @kosa8/ai-sdk-sandbox ai ``` `@kosa8/ai-sdk-sandbox` depends on `@kosa8/sdk`, so you get both. The examples on these pages are ES modules (`.mjs`, or `"type": "module"` in `package.json`) so they can use top-level `await`. ### Check the connection ``` import { Kosa8Client } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); console.log('talking to', kosa8.transport.target); console.log(await kosa8.health()); console.log('version', await kosa8.version()); ``` ``` $ node health.mjs talking to unix:///tmp/k8d/.kosa8/run/kosa8.sock { commit: 'ffaa677', go: 'go1.26.9', phase: '0-foundations', status: 'ok', uptime_seconds: 34, version: '0.9.10' } version 0.9.10 ``` `health()` returns the daemon's `/kosa8/v1/health` answer. `version()` returns only the version string. `transport.target` says where requests go. ## When the daemon is not running Every method rejects with `Kosa8ConnectionError`, and the message says what to do: ``` import { Kosa8Client, Kosa8ConnectionError } from '@kosa8/sdk'; const kosa8 = new Kosa8Client({ socketPath: '/tmp/k8d/no-daemon-here.sock' }); try { await kosa8.health(); } catch (err) { console.log(err instanceof Kosa8ConnectionError, err.name); console.log(err.message); console.log('cause:', err.cause.code); } ``` ``` $ node conn2.mjs true Kosa8ConnectionError kosa8 daemon unreachable at unix:///tmp/k8d/no-daemon-here.sock: connect ENOENT /tmp/k8d/no-daemon-here.sock (is kosa8 installed and its daemon running? start it with `kosa8 daemon start`) cause: ENOENT ``` ## TypeScript Both packages ship `.d.ts` files for ESM and CommonJS, so there is nothing extra to install. Every option and result has a type. This file typechecks with `strict` on and runs unchanged under Node's built-in type stripping: ``` import { Kosa8Client, Kosa8ConnectionError, type Health, type CreateSandboxOptions } from '@kosa8/sdk'; import { createKosa8NetworkSandboxSession } from '@kosa8/ai-sdk-sandbox'; const kosa8 = new Kosa8Client(); const opts: CreateSandboxOptions = { name: 'typed', egressAllow: ['registry.npmjs.org'], memMb: 1024 }; try { const h: Health = await kosa8.health(); console.log(h.version, opts.name, typeof createKosa8NetworkSandboxSession); } catch (err) { if (err instanceof Kosa8ConnectionError) console.error('daemon not running:', err.message); else throw err; } ``` ``` { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "skipLibCheck": true, "noEmit": true }, "files": ["check.ts"] } ``` ``` $ npx tsc -p . && echo CHECK-OK CHECK-OK $ node check.ts 0.9.10 typed function ``` The types catch a misspelt option or an egress value the daemon would refuse: ``` import { Kosa8Client } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); await kosa8.createSandbox({ name: 'x', memoryMb: 512 }); await kosa8.createSandbox({ name: 'y', egress: 'none' }); ``` ``` $ npx tsc --ignoreConfig --noEmit --strict --module NodeNext --moduleResolution NodeNext --target ES2022 --skipLibCheck bad.ts bad.ts(3,40): error TS2353: Object literal may only specify known properties, and 'memoryMb' does not exist in type 'CreateSandboxOptions'. bad.ts(4,40): error TS2322: Type '"none"' is not assignable to type 'EgressPolicy | undefined'. ``` Tested with TypeScript 7.0.2. `tsx` and `ts-node` work as usual. ## CommonJS ``` const { Kosa8Client } = require('@kosa8/sdk'); new Kosa8Client().version().then((v) => console.log('CommonJS require works, daemon', v)); ``` ``` $ node check.cjs CommonJS require works, daemon 0.9.10 ``` ## Apple silicon and Linux #### macOS on Apple silicon Sandboxes run on Apple's Virtualization framework. Images must be built for `linux/arm64`; an image published only for amd64 fails with "Exec format error", because nothing is emulated. Every output on these pages is from this platform. Restoring a saved machine needs the Mac to be unlocked. With the screen locked, rewind, fork, explore and snapshot restore fail (from kosa8 0.9.36 with a message that says so); see [Troubleshooting](https://docs.kosa8.com/guides/js-troubleshooting/#locked). #### x86-64 Linux Sandboxes run on KVM with Cloud Hypervisor. The packages behave the same: the socket path, the API and the errors are identical. Images must be built for `linux/amd64`. `kosa8 doctor` checks KVM access and the helper binaries. untested The SDK examples on these pages were not run on Linux for this release. **Next** [Quickstart](https://docs.kosa8.com/guides/js-quickstart/): a sandbox, a command, a checkpoint and a rewind, in five steps. --- Source: https://docs.kosa8.com/guides/js-install/ # Quickstart Five steps, one file. You will connect to the daemon, boot a microVM with no network, run a command in it, keep a container running and exec into it, then save the running machine, break it, and put it back. You need kosa8 running and `@kosa8/sdk` installed; see [Install and prerequisites](https://docs.kosa8.com/guides/js-install/). Make a file called `quickstart.mjs` and add each step's code to it in turn. The output under each step is what that step printed when the finished file was run once, start to finish. ### Connect ``` import { Kosa8Client } from '@kosa8/sdk'; // 1. Connect. With no options the client uses $HOME/.kosa8/run/kosa8.sock. const kosa8 = new Kosa8Client(); console.log('kosa8', await kosa8.version(), 'at', kosa8.transport.target); ``` ``` kosa8 0.9.10 at unix:///tmp/k8d/.kosa8/run/kosa8.sock ``` `new Kosa8Client()` with no options talks to the daemon's socket in your home folder. Nothing is sent until the first call. [Connecting](https://docs.kosa8.com/guides/js-connecting/) covers other sockets and remote workers. ### Create a sandbox ``` // 2. Create a sandbox: a microVM with its own kernel and no network at all. const { sandbox, boot_ms } = await kosa8.createSandbox({ name: 'demo', egress: 'deny' }); console.log(`sandbox ${sandbox.name}: ${sandbox.state}, egress ${sandbox.egress}, booted in ${boot_ms}ms`); ``` ``` sandbox demo: running, egress deny, booted in 1155ms ``` A sandbox is a microVM with its own kernel, its own container engine and its own network. `egress: 'deny'` means nothing inside can reach the network at all, enforced on your computer where the sandbox cannot touch it. Leave `name` out and the daemon picks one. All the options are on [Sandboxes](https://docs.kosa8.com/guides/js-sandboxes/). ### Run a command ``` // 3. Pull an image into the sandbox (fetched on the host), then run a command to completion. await kosa8.pullIntoSandbox('demo', 'alpine:latest'); const r = await kosa8.run('demo', 'alpine:latest', ['sh', '-c', 'uname -r; echo oops >&2; exit 3']); console.log('exit', r.exitCode, '| stdout', JSON.stringify(r.stdout), '| stderr', JSON.stringify(r.stderr)); ``` ``` exit 3 | stdout "6.18.35\n" | stderr "oops\n" ``` A sandbox's engine does not pull images on its own, so pull first. The image is fetched on the host, which is why this works even though the sandbox has no network. `run` starts a fresh container from the image and waits for it to finish. A non-zero exit code is a result, not an error: the promise resolves with `exitCode: 3`. The kernel version is the sandbox's own, not your Mac's. ### Keep a container running and exec into it ``` // 4. Keep a container running and exec into it. Files written here stay until the sandbox goes. const cid = await kosa8.startContainer('demo', 'alpine:latest', ['sh', '-c', 'while :; do sleep 3600; done']); const w = await kosa8.exec('demo', cid, ['sh', '-c', 'echo v1 > /note; cat /note']); console.log('container', cid.slice(0, 12), '| exec exit', w.exitCode, '| /note =', w.stdout.trim()); ``` ``` container k2-2cf6b3b3 | exec exit 0 | /note = v1 ``` `startContainer` starts a detached container and returns its id. `exec` runs a command in it. Unlike `run`, which starts from the image every time, files written in this container are still there for the next `exec`. ### Checkpoint, break something, rewind ``` // 5. Checkpoint, break something, rewind. The sandbox comes back under the same name, still running. const cp = await kosa8.checkpoint('demo', { label: 'before the risky bit' }); console.log('checkpoint', cp.checkpoint.id, `(${cp.checkpoint.label}) in ${cp.snapshot.snapshot_ms}ms`); await kosa8.exec('demo', cid, ['rm', '/note']); console.log('after rm:', (await kosa8.exec('demo', cid, ['cat', '/note'])).stderr.trim()); const rw = await kosa8.rewind('demo'); console.log('rewound to', rw.checkpoint.id, `in ${rw.restore_ms}ms`); console.log('after rewind: /note =', (await kosa8.exec('demo', cid, ['cat', '/note'])).stdout.trim()); // Clean up: deletes the sandbox, its checkpoints and their snapshots. console.log(await kosa8.deleteSandbox('demo')); ``` ``` checkpoint demo-ckpt-1 (before the risky bit) in 656ms after rm: cat: can't open '/note': No such file or directory rewound to demo-ckpt-1 in 526ms after rewind: /note = v1 { ok: true, checkpoints_dropped: 1 } ``` A checkpoint saves the whole running machine: memory, processes and disks. A rewind puts it back under the same name, with the same container still running, so the same container id still works. `deleteSandbox` also deletes the sandbox's checkpoints, as `kosa8 sandbox rm` does. See [Checkpoints and rewind](https://docs.kosa8.com/guides/js-checkpoints/). ## The whole file ``` import { Kosa8Client } from '@kosa8/sdk'; // 1. Connect. With no options the client uses $HOME/.kosa8/run/kosa8.sock. const kosa8 = new Kosa8Client(); console.log('kosa8', await kosa8.version(), 'at', kosa8.transport.target); // 2. Create a sandbox: a microVM with its own kernel and no network at all. const { sandbox, boot_ms } = await kosa8.createSandbox({ name: 'demo', egress: 'deny' }); console.log(`sandbox ${sandbox.name}: ${sandbox.state}, egress ${sandbox.egress}, booted in ${boot_ms}ms`); // 3. Pull an image into the sandbox (fetched on the host), then run a command to completion. await kosa8.pullIntoSandbox('demo', 'alpine:latest'); const r = await kosa8.run('demo', 'alpine:latest', ['sh', '-c', 'uname -r; echo oops >&2; exit 3']); console.log('exit', r.exitCode, '| stdout', JSON.stringify(r.stdout), '| stderr', JSON.stringify(r.stderr)); // 4. Keep a container running and exec into it. Files written here stay until the sandbox goes. const cid = await kosa8.startContainer('demo', 'alpine:latest', ['sh', '-c', 'while :; do sleep 3600; done']); const w = await kosa8.exec('demo', cid, ['sh', '-c', 'echo v1 > /note; cat /note']); console.log('container', cid.slice(0, 12), '| exec exit', w.exitCode, '| /note =', w.stdout.trim()); // 5. Checkpoint, break something, rewind. The sandbox comes back under the same name, still running. const cp = await kosa8.checkpoint('demo', { label: 'before the risky bit' }); console.log('checkpoint', cp.checkpoint.id, `(${cp.checkpoint.label}) in ${cp.snapshot.snapshot_ms}ms`); await kosa8.exec('demo', cid, ['rm', '/note']); console.log('after rm:', (await kosa8.exec('demo', cid, ['cat', '/note'])).stderr.trim()); const rw = await kosa8.rewind('demo'); console.log('rewound to', rw.checkpoint.id, `in ${rw.restore_ms}ms`); console.log('after rewind: /note =', (await kosa8.exec('demo', cid, ['cat', '/note'])).stdout.trim()); // Clean up: deletes the sandbox, its checkpoints and their snapshots. console.log(await kosa8.deleteSandbox('demo')); ``` ``` $ node quickstart.mjs kosa8 0.9.10 at unix:///tmp/k8d/.kosa8/run/kosa8.sock sandbox demo: running, egress deny, booted in 1155ms exit 3 | stdout "6.18.35\n" | stderr "oops\n" container k2-2cf6b3b3 | exec exit 0 | /note = v1 checkpoint demo-ckpt-1 (before the risky bit) in 656ms after rm: cat: can't open '/note': No such file or directory rewound to demo-ckpt-1 in 526ms after rewind: /note = v1 { ok: true, checkpoints_dropped: 1 } ``` ## The same thing from the command line The SDK and the CLI drive the same daemon, so you can look at what your program did, or finish it by hand: ``` $ kosa8 sandbox ls $ kosa8 checkpoint ls demo $ kosa8 audit ls -n 10 # your program's calls, recorded as actor "tool" ``` **If step 5 fails with "permission denied"** On a Mac, restoring a saved machine needs the screen to be unlocked. The checkpoint succeeds, and the rewind fails with `restore machine state: … “permission denied”` (from kosa8 0.9.36: `macOS blocks restoring a VM while the screen is locked; unlock and retry`). Unlock the Mac and run it again. By then the rewind has already removed the sandbox, and the checkpoint's snapshot is intact: the error message ends with the `restoreSnapshot(…)` call that brings it back. See [Troubleshooting](https://docs.kosa8.com/guides/js-troubleshooting/#locked). **Next** [Sandboxes](https://docs.kosa8.com/guides/js-sandboxes/): every create option, and the rest of a sandbox's life. --- Source: https://docs.kosa8.com/guides/js-quickstart/ # Connecting By default the client talks to the daemon's unix socket in your home folder, and needs no configuration. This page covers the other places it can connect, the environment variables it reads the same way the CLI does, and the two headers that tell the audit log who is calling. ## Where requests go `resolveEndpoint` is what the client uses to decide. It sends nothing, so you can call it to check a configuration: ``` import { resolveEndpoint, defaultSocketPath } from '@kosa8/sdk'; // Where a client would connect, for each way of saying it. No request is sent. console.log('default socket:', defaultSocketPath()); const cases = [ {}, { socketPath: '/var/run/kosa8-ci.sock' }, { host: 'unix:///srv/kosa8/kosa8.sock' }, { host: 'https://worker.example.com', token: 't0ken' }, { host: 'tcp://127.0.0.1:7440' }, { host: 'localhost:7440' }, { env: { KOSA8_HOST: 'https://worker.example.com/', KOSA8_TOKEN: 'from-env' } }, { host: 'http://10.0.0.5:7440', insecure: true }, { host: 'http://10.0.0.5:7440' }, { host: 'ssh://worker' }, ]; for (const opts of cases) { try { const e = resolveEndpoint({ env: {}, ...opts }); console.log(JSON.stringify(opts), '->', e.kind === 'unix' ? `unix ${e.socketPath}` : `${e.url.href} token=${e.token ?? '-'}`); } catch (err) { console.log(JSON.stringify(opts), '->', err.constructor.name + ':', err.message); } } ``` ``` $ node conn1.mjs default socket: /tmp/k8d/.kosa8/run/kosa8.sock {} -> unix /tmp/k8d/.kosa8/run/kosa8.sock {"socketPath":"/var/run/kosa8-ci.sock"} -> unix /var/run/kosa8-ci.sock {"host":"unix:///srv/kosa8/kosa8.sock"} -> unix /srv/kosa8/kosa8.sock {"host":"https://worker.example.com","token":"t0ken"} -> https://worker.example.com/ token=t0ken {"host":"tcp://127.0.0.1:7440"} -> http://127.0.0.1:7440/ token=- {"host":"localhost:7440"} -> http://localhost:7440/ token=- {"env":{"KOSA8_HOST":"https://worker.example.com/","KOSA8_TOKEN":"from-env"}} -> https://worker.example.com/ token=from-env {"host":"http://10.0.0.5:7440","insecure":true} -> http://10.0.0.5:7440/ token=- {"host":"http://10.0.0.5:7440"} -> Kosa8Error: refusing to talk to 10.0.0.5:7440 over plain http: the token and every request would cross the network unencrypted. Use https://10.0.0.5:7440 (a worker behind a TLS-terminating tunnel or proxy), or set insecure: true / KOSA8_INSECURE=1 if this link is already private and trusted {"host":"ssh://worker"} -> Kosa8Error: KOSA8_HOST scheme "ssh" is not supported; use http://, https://, tcp:// or unix:// ``` ### The rules | Setting | Option | Environment | Notes | |---|---|---|---| | Socket path | `socketPath` | (none) | Default `$HOME/.kosa8/run/kosa8.sock`. Ignored when a host is set. | | Host | `host` | `KOSA8_HOST` | `https://h`, `http://h:p`, `tcp://h:p` (same as `http://`), bare `h:p` (also `http://`), or `unix:///path.sock`. Trailing slashes are dropped; a path prefix (`https://h/kosa8`) is kept. | | Token | `token` | `KOSA8_TOKEN` | Sent as `Authorization: Bearer …` to a TCP host. The variable is read only when the host also came from `KOSA8_HOST`; with a `host` option, pass `token` too. | | Plain http | `insecure` | `KOSA8_INSECURE=1` | Plain `http://` to anything but loopback (`localhost`, `127.x`, `::1`) is refused unless set, as in the CLI. | | Actor | `actor` | (none) | `'tool'` (default), `'agent'` or `'human'`. Below. | | Agent token | `agentToken` | `KOSA8_AGENT_TOKEN` | An agent identity. Below. `agentToken: ''` ignores the variable. | | Extra headers | `headers` | (none) | Added to every request. | | Environment | `env` | (none) | Where `KOSA8_*` are read from. Default `process.env`. Useful in tests. | ## Who is calling: actor and agent token Every state-changing request is written to kosa8's hash-chained audit log, with who made it. Two headers set that. **X-Kosa8-Actor**A claim: `human`, `agent` or `tool`. The SDK sends `tool` unless told otherwise, which is the honest value for a program. Set `actor: 'agent'` when a model is choosing the calls, and `'human'` when a person clicked a button in your app. **X-Kosa8-Agent-Token**Proof, not a claim. A token from `kosa8 agent token create NAME`. The daemon verifies it and names the agent in each audit entry. A wrong, expired or revoked token gets 401, never an anonymous pass. Org policies can require one, and can scope what each agent may do. ``` $ kosa8 agent token create ci-reviewer --ttl 1h # prints the agent's id and its token, once (shown here from --json, token cut short) {'agent': {'id': 'agt_371b55e04e83', 'name': 'ci-reviewer', 'created': '2026-10-16T00:37:43.504918Z', 'expires': '2026-10-16T01:37:43.504918Z'}, 'token': 'k8a_371b55e0…'} ``` ``` import { Kosa8Client, Kosa8AuthError } from '@kosa8/sdk'; // KOSA8_AGENT_TOKEN is read from the environment; agentToken: '...' sets it explicitly. const agent = new Kosa8Client({ actor: 'agent' }); await agent.createSandbox({ name: 'agent-box', egress: 'deny' }); await agent.deleteSandbox('agent-box'); // A person at a keyboard driving your tool says so. (agentToken: '' ignores the variable.) const human = new Kosa8Client({ actor: 'human', agentToken: '' }); await human.stopSandbox('runner'); await human.startSandbox('runner'); // A wrong token is refused, never treated as anonymous. const forged = new Kosa8Client({ agentToken: 'k8a_not-a-real-token' }); try { await forged.listSandboxes(); } catch (err) { console.log(err instanceof Kosa8AuthError, err.status, err.message); } ``` ``` $ KOSA8_AGENT_TOKEN=k8a_… node conn3.mjs true 401 agent token is not recognised; mint one with `kosa8 agent token create NAME` $ kosa8 audit ls -n 5 SEQ TIME ACTOR ACTION TARGET STATUS 369 00:38:03 agent sandboxes.create 201 370 00:38:03 agent sandboxes.delete agent-box 200 371 00:38:03 human sandboxes.down runner 200 372 00:38:04 human sandboxes.up runner 200 373 00:38:04 agent agent.auth.refused /kosa8/v1/sa.. 401 ``` With `--json`, the agent's entries carry its verified identity: ``` {'actor': 'agent', 'action': 'sandboxes.create', 'target': None, 'status': 201, 'refs': {'agent': {'id': 'agt_371b55e04e83', 'name': 'ci-reviewer'}}} {'actor': 'agent', 'action': 'sandboxes.delete', 'target': 'agent-box', 'status': 200, 'refs': {'agent': {'id': 'agt_371b55e04e83', 'name': 'ci-reviewer'}}} {'actor': 'human', 'action': 'sandboxes.down', 'target': 'runner', 'status': 200, 'refs': None} ``` Reads such as `listSandboxes` are not audited; only actions that change something are. The refused forged token is recorded too. ## Over TCP: `--listen` and remote workers `kosa8 daemon start --listen HOST:PORT` serves the API over TCP behind a generated token in `~/.kosa8/worker-token`, so another device can drive the machine. While it does, a v0.9.10 daemon *replaces* the unix socket: the CLI follows automatically, but the SDK does not, so point it at the TCP address. (Fixed in kosa8 0.9.36: the unix socket is served beside the TCP listener, unchanged and without a token, so the default client keeps working on that machine; only the TCP port needs the token.) On one machine, over loopback: ``` $ kosa8 daemon start --listen 127.0.0.1:7440 kosa8d started (pid 39066, log /tmp/k8d/.kosa8/log/kosa8d.log) ``` ``` import { Kosa8Client, Kosa8AuthError } from '@kosa8/sdk'; // KOSA8_HOST and KOSA8_TOKEN, as the CLI reads them. const kosa8 = new Kosa8Client(); console.log('target:', kosa8.transport.target); console.log('version over TCP:', await kosa8.version()); console.log('sandboxes:', (await kosa8.listSandboxes()).map((s) => `${s.id}=${s.state}`)); const wrong = new Kosa8Client({ host: 'http://127.0.0.1:7440', token: 'wrong' }); await wrong.listSandboxes().catch((err) => console.log(err instanceof Kosa8AuthError, err.status, err.message)); ``` ``` $ KOSA8_HOST=http://127.0.0.1:7440 KOSA8_TOKEN=$(cat ~/.kosa8/worker-token) node remote.mjs target: http://127.0.0.1:7440 version over TCP: 0.9.10 sandboxes: [ 'runner=stopped' ] true 401 missing or invalid bearer token (set KOSA8_TOKEN to the worker's token) ``` Streaming works the same way over TCP: ``` import { Kosa8Client } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); const up = await kosa8.startSandbox('runner'); console.log('started over TCP in', up.boot_ms, 'ms'); const r = await kosa8.run('runner', 'alpine:latest', ['sh', '-c', 'echo streamed over tcp; exit 2']); console.log(r.exitCode, r.stdout.trim()); ``` ``` started over TCP in 599 ms 2 streamed over tcp ``` With `--listen` set, the default client fails with `connect ENOENT …/kosa8.sock` on v0.9.10, because there is no socket (from 0.9.36 there is). To go back to the socket, run `kosa8 daemon stop`, `kosa8 worker forget`, then `kosa8 daemon start`: `--listen` is remembered until you forget it. **untested A worker on another machine** The SDK accepts the same `KOSA8_HOST` and `KOSA8_TOKEN` as the CLI, so `KOSA8_HOST=https://worker.example.com KOSA8_TOKEN=…` should reach a worker behind a TLS-terminating proxy or tunnel. That was not tested end to end for this release: only the unix socket and loopback TCP above were. Use `https://` across a network; plain `http://` to a non-loopback address is refused unless you set `KOSA8_INSECURE=1`, because the token would cross the network in the clear. ## The socket path on macOS A unix socket path is limited to 104 bytes on macOS (108 on Linux). With a long home folder, for example inside a deep temporary directory, the daemon cannot create its socket and the client cannot reach it: ``` kosa8d: server stopped: listen unix /private/tmp/claude-501/…/longhome/.kosa8/run/kosa8.sock: bind: invalid argument Kosa8ConnectionError: kosa8 daemon unreachable at unix:///private/tmp/claude-501/…/longhome/.kosa8/run/kosa8.sock: connect EINVAL … ``` That path was 131 bytes. Keep `HOME` short, or point `HOME` at a short symlink, as these pages' tests did with `/tmp/k8d`. **Next** [Sandboxes](https://docs.kosa8.com/guides/js-sandboxes/): create options and the life of a sandbox. --- Source: https://docs.kosa8.com/guides/js-connecting/ # Sandboxes A sandbox is a microVM with its own kernel, its own container engine, its own disk and its own network. This page covers every option `createSandbox` takes and every other call in a sandbox's life, each with the output it produced. ## Create one ``` import { Kosa8Client } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); const { sandbox, boot_ms } = await kosa8.createSandbox({ name: 'web', cpus: 2, memMb: 1024, // MiB of guest memory diskGb: 20, // the data disk: images, layers, everything written inside egress: 'deny', labels: { team: 'payments', ticket: 'PAY-142' }, }); console.log(`booted ${sandbox.id} in ${boot_ms}ms`); console.log(sandbox); console.log('list:', (await kosa8.listSandboxes()).map((s) => `${s.id}=${s.state}`)); console.log('get web:', (await kosa8.getSandbox('web'))?.state); console.log('get nope:', await kosa8.getSandbox('nope')); ``` ``` $ node sandboxes1.mjs booted web in 1164ms { id: 'web', name: 'web', created: '2026-10-15T19:57:29.810605-04:00', state: 'running', cpus: 2, mem_mb: 1024, disk_gb: 20, egress: 'deny', ip: '192.168.127.2', labels: { team: 'payments', ticket: 'PAY-142' } } list: [ 'web=running' ] get web: running get nope: undefined ``` `createSandbox` resolves when the machine has booted. The result is `{ sandbox, boot_ms, broker? }`. The sandbox record uses the daemon's own field names (`mem_mb`, `disk_gb`), so it matches what `kosa8 sandbox ls --json` prints. There is no single-sandbox endpoint: `getSandbox` reads the list and returns `undefined` for a name it does not find. ### Every create option | Option | Type | What it does | |---|---|---| | `name` | string | The sandbox's name, which is also its id. The daemon picks one (`sbx1`, `sbx2`, …) when you leave it out. A name already in use is refused. | | `cpus` | number | Virtual CPUs. The daemon default is 2. | | `memMb` | number | Guest memory in MiB. The daemon default is 1024. A checkpoint saves this memory to disk, so smaller is cheaper to save. | | `diskGb` | number | Size of the data disk, which holds images, layers and everything written inside. The daemon default is 32. The file is sparse: it takes real space only as it fills. Your licence sets the ceiling. | | `mounts` | `Mount[]` | Host folders to share: `{ host_path, guest_path, read_only }`. Every container started in the sandbox sees them at `guest_path`. Below. | | `egress` | `EgressPolicy` | The network rule: `'deny'`, `'any'`, `'fakenet'`, `'allow:a.com,*.b.com,10.0.0.0/8'`, or `{ allow: [...] }`. Fixed for the sandbox's life. Left out, the org policy's `default_egress` applies, or `any` when there is none. See [Network and secrets](https://docs.kosa8.com/guides/js-network/). | | `egressAllow` | `string[]` | Shorthand for `egress: { allow: [...] }`. Setting it together with an `egress` other than `'deny'` throws before anything is sent. | | `capture` | `CaptureSpec` | Record the sandbox's traffic to a pcapng file on the host from the first frame: `{ path, max_bytes?, max_files? }`. The path must be absolute. | | `labels` | `Record` | Free-form key–value labels, kept on the sandbox record and shown by `kosa8 sandbox ls --json`. | | `broker` | `BrokerSpec` | Put the sandbox behind an egress broker that adds credentials the sandbox never holds. See [the broker](https://docs.kosa8.com/guides/js-network/#broker). | | `approval`, `signal` | string, `AbortSignal` | Accepted by every call. `approval` is a signed-approval id ([Approvals](https://docs.kosa8.com/guides/js-approvals/)); `signal` cancels the HTTP request. | ## Share a host folder ``` import { Kosa8Client } from '@kosa8/sdk'; import { readFileSync } from 'node:fs'; const kosa8 = new Kosa8Client(); const host = '/tmp/k8d/projects/api'; await kosa8.createSandbox({ name: 'review', egress: 'deny', mounts: [ { host_path: host, guest_path: '/work', read_only: false }, ], }); await kosa8.pullIntoSandbox('review', 'alpine:latest'); // Every container started in the sandbox sees the mount at its guest path. const r = await kosa8.run('review', 'alpine:latest', ['sh', '-c', 'ls -l /work; echo "// reviewed" >> /work/index.js']); console.log(r.stdout); console.log('on the host now:\n' + readFileSync(`${host}/index.js`, 'utf8')); console.log('sandbox record:', (await kosa8.getSandbox('review'))?.mounts); ``` ``` $ node mounts.mjs total 4 -rw-r--r-- 1 root root 36 Oct 15 23:58 index.js on the host now: console.log("hello from the host"); // reviewed sandbox record: [ { host_path: '/tmp/k8d/projects/api', guest_path: '/work', read_only: false } ] ``` The sandbox sees that one folder and nothing else of your computer. A write inside lands on your disk at once. Make it read-only when the work inside should only look: ``` import { Kosa8Client, Kosa8ConflictError, Kosa8NotFoundError } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); // A read-only mount: writes fail inside, with an ordinary exit code. await kosa8.createSandbox({ name: 'ro', egress: 'deny', mounts: [{ host_path: '/tmp/k8d/projects/api', guest_path: '/src', read_only: true }], }); await kosa8.pullIntoSandbox('ro', 'alpine:latest'); const w = await kosa8.run('ro', 'alpine:latest', ['sh', '-c', 'touch /src/new.txt']); console.log('write to a read-only mount:', w.exitCode, w.stderr.trim()); ``` ``` write to a read-only mount: 1 touch: /src/new.txt: Read-only file system ``` **Mounts here, and `--allow-mount` elsewhere** Through the native API, the mount you pass is the mount you get: the caller owns the daemon's socket, so it is trusted to choose. Two other ways in are limited by an allow-list. `kosa8 mcp serve --allow-mount DIR[:rw]` limits the folders an AI agent may share into sandboxes it creates, and `kosa8 daemon start --allow-mount DIR[:rw]` limits Docker API bind mounts. Under a signed org policy whose label lists `mount_paths`, the policy narrows native mounts too. ## Stop, start, and what survives ``` import { Kosa8Client } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); const web = kosa8.sandbox('web'); // a handle: the same methods, bound to one sandbox await web.pull('alpine:latest'); console.log('images:', await web.images()); const cid = await web.startContainer('alpine:latest', ['sh', '-c', 'echo kept > /data.txt; sleep 3600']); console.log('containers:', await web.containers()); console.log('stop:', await web.stop()); console.log('state now:', (await web.info())?.state); const up = await web.start(); console.log('start:', up.sandbox.state, `in ${up.boot_ms}ms`, up.warning ?? ''); console.log('images after restart:', await web.images()); console.log('containers after restart:', await web.containers()); ``` ``` $ node sandboxes2.mjs images: [ 'docker.io/library/alpine:latest' ] containers: [ { id: 'k1-9f6ad3d7', image: 'docker.io/library/alpine:latest', status: 'running', ip: '10.88.0.2' } ] stop: { ok: true, sandbox: { id: 'web', name: 'web', created: '2026-10-15T19:57:29.810605-04:00', state: 'stopped', cpus: 2, mem_mb: 1024, disk_gb: 20, egress: 'deny', labels: { team: 'payments', ticket: 'PAY-142' } }, warning: '' } state now: stopped start: running in 581ms images after restart: [ 'docker.io/library/alpine:latest' ] containers after restart: [] ``` `stopSandbox` shuts the machine down and keeps its disk. `startSandbox` boots it again from that disk: pulled images and written files are still there, but no process is, so the container list is empty. To bring back running processes, take a [snapshot](https://docs.kosa8.com/guides/js-snapshots/) or a [checkpoint](https://docs.kosa8.com/guides/js-checkpoints/) before you stop. A daemon restart also leaves every sandbox stopped, because a sandbox's VM cannot outlive `kosa8d`. `kosa8.sandbox('web')` returns a `SandboxHandle`: the same methods with the sandbox id filled in (`info`, `start`, `stop`, `delete`, `pull`, `run`, `startContainer`, `exec`, `containers`, `images`, `egress`, `snapshot`, `fork`, `explore`, `checkpoint`, `checkpoints`, `rewind`). ## Delete, and wipe ``` // A name that is taken. try { await kosa8.createSandbox({ name: 'ro' }); } catch (err) { console.log(err.constructor.name, err.status, '-', err.message); } // Delete, overwriting the disks first. console.log('delete with wipe:', await kosa8.deleteSandbox('ro', { wipe: true })); // Deleting what is not there. try { await kosa8.deleteSandbox('ro'); } catch (err) { console.log(err instanceof Kosa8NotFoundError, err.status, '-', err.message); } ``` ``` Kosa8ApiError 500 - sandbox ro already exists (run `kosa8 doctor`; check ~/.kosa8/log/) delete with wipe: { files: [ { path: '/tmp/k8d/.kosa8/sandboxes/ro.img', size: 1610612736, overwritten: 1610612736, verified: true, removed: true, filesystem: [Object], caveats: [Array] }, { path: '/tmp/k8d/.kosa8/sandboxes/ro-data.img', size: 34359738368, overwritten: 11395072, verified: true, removed: true, filesystem: [Object], caveats: [Array] } ], ok: true, wipe: { method: 'NIST SP 800-88 Rev. 1 Clear: one pass of a pseudorandom pattern over every allocated extent, flushed, read back and compared, then truncated and unlinked', files: 2, overwritten_bytes: 1622007808, verified: true, caveats: [ 'flash media (SSD, NVMe) may keep old copies in cells the overwrite cannot address; cryptographic erase (FileVault/LUKS, crypto-shred) is the sanitization that reaches them', 'copy-on-write filesystem: the overwrite was written to new blocks, and blocks shared with a clone or filesystem snapshot keep the old data until nothing references them' ] }, checkpoints_dropped: 0 } true 404 - no such sandbox: ro (list sandboxes with `kosa8 sandbox ls`) ``` - **A taken name** comes back as HTTP 500 (`Kosa8ApiError`) in v0.9.10, not as a 409. Check with `getSandbox` first if you reuse names. Fixed in kosa8 0.9.36: it is a `Kosa8ConflictError` (409), "sandbox ro already exists (choose another name, …)". - **`wipe: true`** overwrites the sandbox's disks before removing them and reports exactly what it did, with the honest caveats for SSDs and copy-on-write filesystems. It writes as many bytes as the disks hold, so it takes longer and needs free space on APFS. - **Checkpoints go too.** `deleteSandbox` also removes the sandbox's checkpoints and their snapshots, as `kosa8 sandbox rm` does, and reports how many in `checkpoints_dropped`. Pass `keepCheckpoints: true` to keep them. - **Deleting a name that does not exist** rejects with `Kosa8NotFoundError`. ## Images and containers Each sandbox has its own private image store and container engine. Nothing is shared with another sandbox or with your host. | Call | Returns | Notes | |---|---|---| | `pullIntoSandbox(id, ref)` | `{ ok, image }` | Fetched on the host and checked against the org's registry allow-list, then loaded into the sandbox. Works under `egress: 'deny'`. | | `sandboxImages(id)` | `string[]` | Full references, such as `docker.io/library/alpine:latest`. | | `sandboxContainers(id)` | `Container[]` | `{ id, image, status, exit?, ip? }`, including exited ones. | | `sandboxEgress(id)` | `EgressStatus` | The rule enforced on the host and its counters. See [Network](https://docs.kosa8.com/guides/js-network/). | | `pull(ref)` | `{ ok, image }` | Pulls into the *shared* engine, the one the Docker API and `kosa8 run` use, not into a sandbox. | ``` import { Kosa8Client } from '@kosa8/sdk'; // The shared engine (the one the Docker API and `kosa8 run` use), not a sandbox. const kosa8 = new Kosa8Client(); console.log(await kosa8.pull('alpine:3.20')); ``` ``` $ node hostpull.mjs { image: 'docker.io/library/alpine:3.20', ok: true } ``` ## Packet capture Pass `capture` at creation to record every frame from the first one. The file is written on the host, where the sandbox cannot reach it, and `sandboxEgress` reports where it is. The [Network](https://docs.kosa8.com/guides/js-network/) page creates a sandbox with `capture: { path: '/tmp/k8d/allow1.pcapng' }`; after one request the file held 18 KB of pcapng. Open it with Wireshark or `tshark -r`. **Next** [Network and secrets](https://docs.kosa8.com/guides/js-network/): allow-lists, refusals, and the broker that keeps API keys out. --- Source: https://docs.kosa8.com/guides/js-sandboxes/ # Network and secrets Every sandbox has one network rule, chosen when it is created and enforced on your computer, outside the virtual machine. This page shows each rule from Node, what a refusal looks like from inside, how to read the counters, and the broker that lets a sandbox use an API key it never holds. The rules themselves, and where they are enforced, are explained in [Network rules](https://docs.kosa8.com/guides/network/). This page is about driving them from code. ## The rules | `egress` | What leaves the sandbox | |---|---| | `'deny'` | Nothing: no connection, no datagram, no DNS lookup. | | `'allow:a.com,*.b.com,10.0.0.0/8'` or `{ allow: [...] }` or `egressAllow: [...]` | Only those names, wildcards, IPv4 addresses and IPv4 networks. Everything else is refused. DNS is answered on your computer, so a name resolves only if it is on the list. | | `'any'` | Everything, unlogged. This is what you get when you set nothing and no org policy sets `default_egress`. | | `'fakenet'` | Nothing leaves; a simulated internet on your computer answers and records every request. Meant for malware analysis (`kosa8 lab`). Not demonstrated on this page. | ## An allow-list, and no network at all ``` import { Kosa8Client } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); const fetchBoth = ['sh', '-c', 'for u in https://example.com https://github.com; do ' + ' if wget -q -T 5 -O /dev/null "$u"; then echo "$u reached"; else echo "$u refused"; fi; ' + 'done']; // Only example.com. Everything else is refused on the host. await kosa8.createSandbox({ name: 'allow1', egressAllow: ['example.com'], capture: { path: '/tmp/k8d/allow1.pcapng' }, }); await kosa8.pullIntoSandbox('allow1', 'alpine:latest'); console.log((await kosa8.run('allow1', 'alpine:latest', fetchBoth)).stdout); console.log(await kosa8.sandboxEgress('allow1')); // No network at all. await kosa8.createSandbox({ name: 'deny1', egress: 'deny' }); await kosa8.pullIntoSandbox('deny1', 'alpine:latest'); console.log((await kosa8.run('deny1', 'alpine:latest', fetchBoth)).stdout); console.log(await kosa8.sandboxEgress('deny1')); ``` ``` $ node net1.mjs https://example.com reached https://github.com refused { policy: 'allow:example.com', stats: { passed: 13, denied: 8, dropped: 7, dns_answered: 6, dns_refused: 8 }, capture: '/tmp/k8d/allow1.pcapng' } https://example.com refused https://github.com refused { policy: 'deny', stats: { passed: 2, denied: 0, dropped: 8, dns_answered: 0, dns_refused: 0 } } ``` `sandboxEgress` returns the rule the host enforces and what it has decided so far: frames passed, denied, and dropped as link noise (IPv6 and multicast chatter every Linux machine sends at boot), and DNS questions answered and refused. With a `*.domain` entry it also lists the names each wildcard has matched. ## What a refusal looks like from inside ``` import { Kosa8Client } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); // What a refused request looks like from inside. const r = await kosa8.run('allow1', 'alpine:latest', ['wget', '-T', '5', '-O', '/dev/null', 'https://github.com']); console.log('exit', r.exitCode, '|', r.stderr.trim()); ``` ``` exit 1 | wget: bad address 'github.com' ``` A name that is not on the list does not resolve, so the program fails at once with an ordinary error and a non-zero exit code. Nothing is thrown in your Node code: `run` resolves with the exit code. Each refusal is also written to the audit log (`kosa8 audit ls` shows `sandbox.egress.deny` entries). ## Rules that are refused A rule kosa8 does not fully understand is refused, never guessed at. The daemon answers with HTTP 400, and two mistakes are caught in the client before anything is sent: ``` // Policies the daemon refuses, and the two mistakes the client refuses before sending anything. for (const egress of ['allow:*.com', 'allow:*', 'allow:2001:db8::1', 'open']) { try { await kosa8.createSandbox({ name: 'bad', egress }); } catch (err) { console.log(`${egress} -> ${err.constructor.name} ${err.status ?? ''}: ${err.message}`); } } for (const opts of [{ egress: 'any', egressAllow: ['example.com'] }, { egress: { allow: [] } }]) { try { await kosa8.createSandbox({ name: 'bad', ...opts }); } catch (err) { console.log(`${JSON.stringify(opts)} -> ${err.constructor.name}: ${err.message}`); } } ``` ``` allow:*.com -> Kosa8BadRequestError 400: egress allow "*.com": com is a public suffix, under which anyone can register a name, so the wildcard would admit strangers' hosts; allow a domain below it, as in *.example.com allow:* -> Kosa8BadRequestError 400: egress allow "*": a bare * would allow every name; use --egress any, or list *.domain entries allow:2001:db8::1 -> Kosa8BadRequestError 400: egress allow "2001:db8::1": only IPv4 is routed from a sandbox; list an IPv4 address or a hostname open -> Kosa8BadRequestError 400: egress policy "open": want any, deny, fakenet, or allow:HOST[,HOST...] {"egress":"any","egressAllow":["example.com"]} -> Kosa8Error: egressAllow already limits the network to the hosts it lists; do not also set egress {"egress":{"allow":[]}} -> Kosa8Error: an egress allow-list needs at least one host; use egress: "deny" for no network ``` ## The rule cannot change while the sandbox runs There is no call to change a running sandbox's egress, by design: a rule an agent could widen from inside the session would not be a rule. To change it, create a new sandbox. To carry state across, use a [snapshot](https://docs.kosa8.com/guides/js-snapshots/), which keeps the egress it was taken with, or a host folder [mount](https://docs.kosa8.com/guides/js-sandboxes/#mounts). ## Keep API keys out: the broker An agent needs to call a model API, and anything inside the sandbox can read whatever key you put there. The broker solves this. It is a proxy on your computer, one per sandbox, and the only way out of it. You give the daemon the real key once, over your socket. The sandbox gets a placeholder. The broker swaps the placeholder for the real key on the way out, and only for the upstream, methods and paths you named. ``` import { Kosa8Client } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); // The real secret goes to the daemon over your socket and stays in its memory. const created = await kosa8.createSandbox({ name: 'brokered', egress: 'deny', // with a broker, the only way out is the broker broker: { allow: [{ host: 'example.com' }], // reachable through the broker's proxy, no credential routes: [{ name: 'httpbin', // reachable at /httpbin upstream: 'https://httpbin.org', header: 'x-api-key', // the header the broker sets on the way out methods: ['GET'], paths: ['/headers'], secret: 'sk-live-THE-REAL-KEY', }], }, }); console.log('sandbox.broker:', created.sandbox.broker); console.log('broker:', created.broker); const { url, placeholders } = created.broker; await kosa8.pullIntoSandbox('brokered', 'curlimages/curl:latest'); const r = await kosa8.run('brokered', 'curlimages/curl:latest', ['sh', '-c', [ 'echo "the sandbox holds: $API_KEY"', 'curl -s -H "x-api-key: $API_KEY" "$BASE/headers" | grep -i x-api-key', 'curl -s -o /dev/null -w "example.com via the proxy: HTTP %{http_code}\\n" https://example.com', 'curl -s -o /dev/null https://github.com || echo "github.com: refused (curl exit $?)"', ].join('; ')], { env: { API_KEY: placeholders.httpbin, BASE: `${url}/httpbin`, HTTPS_PROXY: url, NO_PROXY: new URL(url).hostname, }, }); console.log(r.stdout); ``` ``` $ node broker.mjs sandbox.broker: { url: 'http://192.168.127.254:59393', hosts: [ 'example.com' ], routes: [ 'httpbin -> httpbin.org' ] } broker: { placeholders: { httpbin: 'kosa8-placeholder-85d9e0acdc031a0fc7003a39af4b069d' }, url: 'http://192.168.127.254:59393' } the sandbox holds: kosa8-placeholder-85d9e0acdc031a0fc7003a39af4b069d "X-Api-Key": "sk-live-THE-REAL-KEY" example.com via the proxy: HTTP 200 github.com: refused (curl exit 7) ``` Read the output from the top. The sandbox's environment holds only the placeholder. httpbin.org, which echoes the headers it received, got the real key, because the broker added it on the way out. `example.com` is on the broker's `allow` list and passes through its proxy with no credential. Everything else is refused. After this run, a search of the whole state directory for the secret found nothing: the daemon keeps it in memory for the life of the sandbox and writes it nowhere, not to the sandbox record, a snapshot, or the audit log. ``` $ grep -rl "THE-REAL-KEY" /tmp/k8d/.kosa8/ | wc -l 0 ``` ### The broker spec | Field | Meaning | |---|---| | `allow[]` | Hosts reachable through the broker's proxy with no credential: `{ host, ports?, methods? }`. `host` is an exact name, a `*.domain` wildcard or an IPv4 address. Ports default to 443 and 80. | | `routes[]` | Credentialed upstreams: `{ name, upstream, header, secret, methods?, paths?, scheme? }`. The sandbox calls `//…`, and the broker forwards to `upstream` with `header` set from `secret`. | | result `broker.url` | The broker as the sandbox sees it. Use it as `HTTPS_PROXY`, and as the base URL of each route. | | result `broker.placeholders` | Route name to the stand-in the sandbox should present. Returned by `createSandbox` and nowhere else, so keep it. | For a model API, point the client inside the sandbox at the route and give it the placeholder as its key: for Anthropic, a route with `upstream: 'https://api.anthropic.com'` and `header: 'x-api-key'`, then `ANTHROPIC_BASE_URL=/anthropic` and `ANTHROPIC_API_KEY=`, as in the worked example on [For developers](https://docs.kosa8.com/guides/developers/). That route was not run for this page; the httpbin route above was. [Keep API keys out](https://docs.kosa8.com/guides/credentials/) covers the broker's design. **Tool notes from testing** - Busybox `wget` (in `alpine`) works for route calls but not as an HTTPS client through the proxy: the broker answered its proxied requests with `400 Bad Request`. `curl` works both ways, so the example uses `curlimages/curl`. Fixed in kosa8 0.9.36: busybox sends `GET https://…` to the proxy instead of `CONNECT`, and the broker now forwards that, admitting it by host, port and method and verifying the certificate itself. - The broker refuses `egress: 'any'`: beside an open network it would be optional. Leave `egress` unset or `'deny'`. - The secret lives only in the daemon's memory. A brokered sandbox that is stopped and started, restored or forked comes back with its network closed and no broker. Create a fresh one each time. **Next** [Running code](https://docs.kosa8.com/guides/js-running-code/): run, exec, streaming output, stdin and time limits. --- Source: https://docs.kosa8.com/guides/js-network/ # Running code Inside a sandbox, code runs in containers on the sandbox's own engine. There are three shapes: a container run to completion, commands executed in a container that keeps running, and an interactive container you feed through stdin. This page shows each one, with streaming, exit codes, time limits and errors. | Call | What it does | Returns | |---|---|---| | `run(id, image, args?, opts?)` | A fresh container from the image, run to completion. Options: `env`, `onStdout`, `onStderr`, `onStatus`. | `ExecResult` | | `startContainer(id, image, args?)` | A detached container that keeps running. | its id | | `exec(id, container, args, opts?)` | A command in a running container. Options: `onStdout`, `onStderr`. | `ExecResult` | | `execFrames(id, container, args)` | The same, as the raw stream of frames. | async iterable of `Frame` | | `startInteractive(id, image, args?, { env?, tty? })` | A container with stdin held open. | its id | | `writeStdin(id, container, data)` | Stream bytes into its stdin; stdin closes when the body ends. | `{ ok }` | | `attach(id, container, opts?)` | Follow a container's output until it exits. | `ExecResult` | `ExecResult` is `{ exitCode, stdout, stderr, stdoutBytes, stderrBytes }`: the text decoded as UTF-8, and the raw bytes as Buffers for binary output. Arguments are always an argv array. No shell is involved unless you run one, so nothing in an argument is interpreted. The examples share one sandbox, created like this: ``` import { Kosa8Client } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); await kosa8.createSandbox({ name: 'runner', egress: 'deny' }); await kosa8.pullIntoSandbox('runner', 'alpine:latest'); ``` ## run: to completion, streaming as it goes ``` // run: a fresh container from the image, to completion. Output streams as it is produced. const t0 = Date.now(); const r = await kosa8.run('runner', 'alpine:latest', ['sh', '-c', 'for i in 1 2 3; do echo "tick $i ($GREETING)"; sleep 1; done; echo done >&2; exit 7'], { env: { GREETING: 'hello' }, // or ['GREETING=hello'] onStdout: (b) => process.stdout.write(`[${Date.now() - t0}ms] ${b}`), onStderr: (b) => process.stdout.write(`[${Date.now() - t0}ms] stderr: ${b}`), }); console.log(r.exitCode, JSON.stringify(r.stdout), JSON.stringify(r.stderr), r.stdoutBytes.length); ``` ``` $ node run1.mjs [162ms] tick 1 (hello) [1153ms] tick 2 (hello) [2155ms] tick 3 (hello) [3157ms] stderr: done 7 "tick 1 (hello)\ntick 2 (hello)\ntick 3 (hello)\n" "done\n" 45 ``` The callbacks fire as output is produced, a second apart here, and the full text is collected in the result as well. The exit code 7 is in the result; the promise resolves. A command's failure is information you act on, not an exception. `onStatus` receives progress lines the daemon emits while it prepares the run. The image must already be in the sandbox: `run` does not pull (see the errors below). ## exec: commands in a container that stays up ``` import { Kosa8Client } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); const box = kosa8.sandbox('runner'); // A long-lived container, then several commands in it. State carries over between execs. const cid = await box.startContainer('alpine:latest', ['sh', '-c', 'while :; do sleep 3600; done']); console.log('container', cid); await box.exec(cid, ['sh', '-c', 'mkdir -p /app && echo 41 > /app/n']); const r = await box.exec(cid, ['sh', '-c', 'echo $(( $(cat /app/n) + 1 ))']); console.log('exec ->', r.exitCode, r.stdout.trim()); // Arguments are passed as an argv array: no shell unless you ask for one. console.log((await box.exec(cid, ['echo', '$HOME', '; rm -rf /'])).stdout.trim()); // execFrames: the raw stream, one frame at a time, for output you want as it arrives. for await (const f of kosa8.execFrames('runner', cid, ['sh', '-c', 'echo a; echo b >&2; exit 4'])) { console.log({ ...f, data: f.data?.toString() }); } ``` ``` $ node run2.mjs container k2-99f6f9b7 exec -> 0 42 $HOME ; rm -rf / { stream: 'stdout', data: 'a\n' } { stream: 'stderr', data: 'b\n' } { done: true, ok: true, exit: 4, data: undefined } ``` - State carries over between `exec` calls in the same container: the file written by the first is read by the second. A container started by `run` is gone when it finishes, so state you want to keep belongs in a long-lived container or a mount. - The third call shows the argv rule: `$HOME ; rm -rf /` reached `echo` as plain text. - `execFrames` yields each frame as it arrives: output frames with a `stream` and decoded `data`, then a last frame with `done` and the `exit` code. Breaking out of the loop closes the request. ## Interactive containers and stdin `exec` has no stdin. For a process that reads input, start an interactive container, attach to its output, then write to it: ``` import { Kosa8Client } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); // A container with stdin held open. Here, a shell that reads commands from stdin. const cid = await kosa8.startInteractive('runner', 'alpine:latest', ['sh']); console.log('interactive container', cid); // attach follows the container's output until it exits; start it before writing. const done = kosa8.attach('runner', cid, { onStdout: (b) => process.stdout.write(`out: ${b}`) }); // writeStdin streams bytes in. When the body ends, the container's stdin is closed. console.log(await kosa8.writeStdin('runner', cid, 'echo hello from stdin\nuname -m\nexit 5\n')); const r = await done; console.log('attach ->', r.exitCode, JSON.stringify(r.stdout)); ``` ``` $ node run3.mjs interactive container k7-f0b75fe7 out: hello from stdin out: aarch64 { ok: true } attach -> 5 "hello from stdin\naarch64\n" ``` Each container's stdin takes one `writeStdin` body, and closes when that body ends. A second call returns `{ ok: true }` but its bytes go nowhere; in our test, a `cat` saw end-of-file after the first call and exited. To feed input over time, pass an async iterable, which is streamed chunk by chunk: ``` import { Kosa8Client } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); const cid = await kosa8.startInteractive('runner', 'alpine:latest', ['sh', '-c', 'while read line; do echo "got: $line"; done; echo "stdin closed"']); const done = kosa8.attach('runner', cid, { onStdout: (b) => process.stdout.write(b) }); // One writeStdin call per container: stdin closes when its body ends. // To feed input over time, pass an async iterable; it is streamed chunk by chunk. async function* lines() { for (const l of ['one', 'two', 'three']) { yield l + '\n'; await new Promise((r) => setTimeout(r, 500)); } } await kosa8.writeStdin('runner', cid, lines()); console.log('exit', (await done).exitCode); ``` ``` $ node run3c.mjs got: one got: two got: three stdin closed exit 0 ``` `tty: true` gives the container a terminal, for programs that change behaviour when they see one. It was not exercised for this page. ## Time limits, and errors ``` import { Kosa8Client } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); // A time limit: the promise rejects with the signal's reason. try { await kosa8.run('runner', 'alpine:latest', ['sleep', '30'], { signal: AbortSignal.timeout(2000) }); } catch (err) { console.log('aborted:', err.name, '-', err.message); } // An image the sandbox does not have. try { await kosa8.run('runner', 'python:3.13-alpine', ['python3', '-V']); } catch (err) { console.log(err.constructor.name, err.status ?? '', '-', err.message); } // A container that does not exist. try { await kosa8.exec('runner', 'k99-nope', ['true']); } catch (err) { console.log(err.constructor.name, err.status ?? '', '-', err.message); } // A command that is not in the image. const r = await kosa8.run('runner', 'alpine:latest', ['python3', '-V']).catch((e) => e); console.log(r.constructor.name, r.exitCode ?? '', r.message ?? r.stderr); ``` ``` $ node run4.mjs aborted: TimeoutError - The operation was aborted due to timeout Kosa8StreamError - image "docker.io/library/python:3.13-alpine" not found in engine: image "docker.io/library/python:3.13-alpine": not found Kosa8StreamError - no such container: k99-nope Kosa8StreamError failed to create shim task: OCI runtime create failed: executable file `python3` not found in $PATH: No such file or directory ``` Two kinds of failure reach you. An error before the run starts, such as a refusal by policy, is an HTTP error and arrives as the matching class (`Kosa8PolicyError`, `Kosa8NotFoundError`, …). An error once the stream has started, such as an image that is not in the sandbox or a missing executable, arrives as `Kosa8StreamError`, with any output produced so far in `err.stdout` and `err.stderr`. Every class is on the [Errors reference](https://docs.kosa8.com/guides/js-errors/). **Aborting stops the request, not the process** A `signal` ends the HTTP request and rejects the promise with the signal's reason. The command inside the sandbox keeps running: right after the 2-second abort above, `sandboxContainers` still listed the `sleep 30` container as `running`. To bound how long code runs, put the limit inside the sandbox, with `timeout`, and see the [untrusted code recipe](https://docs.kosa8.com/guides/js-recipes/#llm-code) for the PID 1 detail that makes it work. ## Environment variables `run` and `startInteractive` take `env` as an object (`{ GREETING: 'hello' }`) or as Docker-style strings (`['GREETING=hello']`). `startContainer` and `exec` take none: set variables inside the command (`['sh', '-c', 'FOO=1 my-tool']`), or use the [AI SDK adapter](https://docs.kosa8.com/guides/js-ai-sdk/), which applies `env` and a working directory to every command for you. **Next** [Files](https://docs.kosa8.com/guides/js-files/): getting files into and out of a sandbox. --- Source: https://docs.kosa8.com/guides/js-running-code/ # Files kosa8's native API has no file endpoint for sandboxes. There are three ways to move files instead, and which one fits depends on whether the files live on your machine or inside the sandbox. **A mount**Share a host folder when you create the sandbox. Files are on your disk; the sandbox reads and writes them in place, at full speed, with no copying. Best for a repository an agent edits. See [Share a host folder](https://docs.kosa8.com/guides/js-sandboxes/#mounts). **Through exec**Write with `base64 -d` and read with `cat`, inside a running container. Works for any file in the container, and needs `sh` and `base64` in the image. The helpers below are about 20 lines. **The AI SDK adapter** `readTextFile`, `writeBinaryFile`, streams and the rest, already written, doing the same exec-and-base64 work underneath. ## Through exec, with @kosa8/sdk ``` import { Kosa8Client } from '@kosa8/sdk'; import { createHash, randomBytes } from 'node:crypto'; const kosa8 = new Kosa8Client(); const sha = (b) => createHash('sha256').update(b).digest('hex').slice(0, 16); // There is no file endpoint: files go in and out through exec. The image needs sh and base64. async function writeFile(sandbox, container, path, data) { const b64 = Buffer.from(data).toString('base64'); const CHUNK = 96 * 1024; // one exec argument stays well under Linux's 128 KiB limit let r = await kosa8.exec(sandbox, container, ['sh', '-c', 'mkdir -p "$(dirname "$1")" && : > "$1.tmp"', 'sh', path]); for (let i = 0; i < b64.length && r.exitCode === 0; i += CHUNK) { r = await kosa8.exec(sandbox, container, ['sh', '-c', 'printf %s "$2" | base64 -d >> "$1.tmp"', 'sh', path, b64.slice(i, i + CHUNK)]); } if (r.exitCode === 0) r = await kosa8.exec(sandbox, container, ['mv', '-f', `${path}.tmp`, path]); if (r.exitCode !== 0) throw new Error(`write ${path}: ${r.stderr.trim()}`); } async function readFile(sandbox, container, path) { const r = await kosa8.exec(sandbox, container, ['cat', '--', path]); if (r.exitCode !== 0) throw new Error(`read ${path}: ${r.stderr.trim()}`); return r.stdoutBytes; // the raw bytes; r.stdout is the same decoded as UTF-8 } const cid = await kosa8.startContainer('runner', 'alpine:latest', ['sh', '-c', 'while :; do sleep 3600; done']); await writeFile('runner', cid, '/work/hello.txt', 'hi from the host\n'); console.log((await readFile('runner', cid, '/work/hello.txt')).toString()); const blob = randomBytes(1024 * 1024); // 1 MiB of binary const t0 = Date.now(); await writeFile('runner', cid, '/work/blob.bin', blob); const back = await readFile('runner', cid, '/work/blob.bin'); console.log(`1 MiB round trip in ${Date.now() - t0}ms: ${sha(blob)} -> ${sha(back)}, ${back.length} bytes`); await readFile('runner', cid, '/work/missing.txt').catch((e) => console.log(e.message)); ``` ``` $ node files1.mjs hi from the host 1 MiB round trip in 597ms: 3044a160fdc5bac4 -> 3044a160fdc5bac4, 1048576 bytes read /work/missing.txt: cat: can't open '/work/missing.txt': No such file or directory ``` Points that matter, all visible in the code: - **Chunk the base64.** The data travels as an exec argument, and Linux caps one argument at 128 KiB. 96 KiB of base64 per call stays well under it. - **Pass data as arguments, not inside the script.** `sh -c 'script' sh "$1" "$2"` keeps paths and data out of the shell's parser, so a path with spaces or quotes is just a path. - **Write to a temporary name, then rename.** A reader never sees half a file. - **Read with `stdoutBytes`** for binary. `stdout` is the same bytes decoded as UTF-8, which would mangle anything that is not text. The image needs `sh` and `base64`. Alpine, Debian, Ubuntu and the official Node and Python images have both. A distroless image has neither; use a mount for those. ## With the AI SDK adapter The adapter's session does all of this for you. Paths are relative to its working directory, `/workspace` by default, and parent directories are created on write. ``` import { createKosa8NetworkSandboxSession } from '@kosa8/ai-sdk-sandbox'; import { createHash, randomBytes } from 'node:crypto'; const sha = (b) => createHash('sha256').update(b).digest('hex').slice(0, 16); const session = await createKosa8NetworkSandboxSession({ networkPolicy: { mode: 'deny-all' } }); const sb = session.restricted(); try { // Binary in and out. const blob = randomBytes(300 * 1024); await sb.writeBinaryFile({ path: 'data/blob.bin', content: blob }); const back = await sb.readBinaryFile({ path: 'data/blob.bin' }); console.log('binary:', sha(blob), '->', sha(back), back.length, 'bytes'); // Streams: write from a web ReadableStream, read as one. const enc = new TextEncoder(); const content = new ReadableStream({ start(c) { for (let i = 1; i <= 3; i++) c.enqueue(enc.encode(`row ${i}\n`)); c.close(); }, }); await sb.writeFile({ path: 'data/rows.csv', content }); let text = ''; for await (const chunk of await sb.readFile({ path: 'data/rows.csv' })) text += new TextDecoder().decode(chunk); console.log('stream:', JSON.stringify(text)); // Missing files read as null; a directory is an error. console.log('missing:', await sb.readFile({ path: 'nope' }), await sb.readBinaryFile({ path: 'nope' })); await sb.readTextFile({ path: 'data' }).catch((e) => console.log('directory:', e.message)); } finally { await session.destroy(); } process.exit(0); ``` ``` $ node adfiles.mjs binary: dec126367973ae47 -> dec126367973ae47 307200 bytes stream: "row 1\nrow 2\nrow 3\n" missing: null null directory: /workspace/data is a directory ``` | Method | Notes | |---|---| | `readTextFile({ path, encoding?, startLine?, endLine? })` | Text, or `null` if missing. `startLine`/`endLine` select lines (1-based). | | `readBinaryFile({ path })` | `Uint8Array`, or `null`. | | `readFile({ path })` | A web `ReadableStream`, or `null`. Streams from `cat`. | | `writeTextFile({ path, content, encoding? })` | Creates parent directories; replaces atomically. | | `writeBinaryFile({ path, content })` | Same, from a `Uint8Array`. | | `writeFile({ path, content })` | Same, from a `ReadableStream`, chunked as it reads. | Each accepts `abortSignal`. A path that is a directory is an error, not `null`. ## Which to choose | Situation | Use | |---|---| | An agent edits a repository you will review with `git diff` | A writable mount | | Inputs the code must not change | A read-only mount | | Generated code, test fixtures, one file at a time | Through exec, or the adapter | | Results to collect after a run | A mount if they are large; `cat` through exec if not | | Large datasets, gigabytes | A mount. Exec is about 600 ms per MiB round trip in our test, fine for source files and slow for bulk data. | **Next** [Checkpoints and rewind](https://docs.kosa8.com/guides/js-checkpoints/): an undo button for the whole machine. --- Source: https://docs.kosa8.com/guides/js-files/ # Checkpoints and rewind A checkpoint saves the whole running machine: memory, processes and disks. A rewind puts the sandbox back to it under the same name, still running. Take one before anything you could not otherwise undo. ## Checkpoint, change, rewind ``` import { Kosa8Client } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); const db = kosa8.sandbox('db'); await kosa8.createSandbox({ name: 'db', egress: 'deny' }); await db.pull('alpine:latest'); // A process with state in memory: a counter that only lives in the shell's variable. const cid = await db.startContainer('alpine:latest', ['sh', '-c', 'echo v1 > /schema; i=0; while :; do i=$((i+1)); echo $i > /tmp/tick; sleep 1; done']); const sh = async (c) => (await db.exec(cid, ['sh', '-c', c])).stdout.trim(); await new Promise((r) => setTimeout(r, 2000)); const a = await db.checkpoint({ label: 'before migration' }); console.log('took', a.checkpoint.id, `"${a.checkpoint.label}"`, 'tick', await sh('cat /tmp/tick')); await sh('echo v2 > /schema'); const b = await db.checkpoint({ label: 'after the schema change' }); console.log('took', b.checkpoint.id, `"${b.checkpoint.label}"`); await sh('rm /schema'); console.log('stack:', (await db.checkpoints()).map((c) => `${c.id} (${c.label})`)); // Back to the first one by label. The later checkpoint describes a future that did not happen. const rw = await db.rewind({ to: 'before migration' }); console.log(`rewound to ${rw.checkpoint.id} in ${rw.restore_ms}ms; dropped`, rw.dropped); console.log('schema:', await sh('cat /schema'), '| tick:', await sh('cat /tmp/tick')); await new Promise((r) => setTimeout(r, 2000)); console.log('2s later, tick:', await sh('cat /tmp/tick'), '(the same loop, still running)'); console.log('stack:', (await db.checkpoints()).map((c) => c.id)); ``` ``` $ node ckpt.mjs took db-ckpt-1 "before migration" tick 3 took db-ckpt-2 "after the schema change" stack: [ 'db-ckpt-1 (before migration)', 'db-ckpt-2 (after the schema change)' ] rewound to db-ckpt-1 in 509ms; dropped [ 'db-ckpt-2' ] schema: v1 | tick: 3 2s later, tick: 5 (the same loop, still running) stack: [ 'db-ckpt-1' ] ``` What the output shows: - **Processes come back.** The counter loop was not restarted. After the rewind it reads 3, its value at the checkpoint, and two seconds later it reads 5. The same container id still works. - **Files come back.** `/schema` was changed to v2 and then deleted; after the rewind it is v1 again. - **Later checkpoints are dropped.** `db-ckpt-2` described a state the machine no longer reached, so the rewind deleted it and its snapshot, and says so in `dropped`. - **`to`** takes a checkpoint id or a label, compared without case. Without it, `rewind` goes to the most recent checkpoint. ## The CLI sees the same checkpoints By default the SDK keeps its list in `~/.kosa8/checkpoints.json`, the file the CLI, the MCP server and the Mac app use. So a checkpoint your program took can be rewound by hand, and the other way round: ``` $ kosa8 checkpoint ls db CHECKPOINT WHEN TAKEN WHY *db-ckpt-1 just now you before migration * is where `kosa8 rewind db` goes ``` ## How checkpoints work The daemon has no checkpoint endpoint. A checkpoint is an ordinary [snapshot](https://docs.kosa8.com/guides/js-snapshots/) with the id `-ckpt-`, plus a record of its label and order kept on the client side, exactly as the CLI does it. | Call | What it does | |---|---| | `checkpoint(id, { label? })` | Takes the snapshot, then records it. Returns `{ checkpoint, evicted, snapshot }`. | | `listCheckpoints(id)` | The sandbox's checkpoints, oldest first: `{ id, sandbox, label, created, auto? }`. | | `rewind(id, { to? })` | Deletes the sandbox, restores the checkpoint's snapshot under the same name, then drops later checkpoints. Returns `{ sandbox, checkpoint, dropped, restore_ms }`. | | `deleteSandbox(id)` | Also deletes the sandbox's checkpoints and their snapshots, unless `keepCheckpoints: true`. | ### Where the list is kept ``` import { Kosa8Client, MemoryCheckpointStore, FileCheckpointStore } from '@kosa8/sdk'; // Default: the CLI's own file, so `kosa8 checkpoint ls` and `kosa8 rewind` see these. const shared = new Kosa8Client(); console.log('default store:', shared.checkpoints.store.path); // Private to this process: nothing is written to disk, and the CLI never sees them. const isolated = new Kosa8Client({ checkpointStore: new MemoryCheckpointStore() }); await isolated.createSandbox({ name: 'scratch', egress: 'deny', memMb: 512 }); const c = await isolated.checkpoint('scratch', { label: 'private' }); console.log('memory store has:', (await isolated.listCheckpoints('scratch')).map((x) => x.id)); console.log('file store has: ', await shared.listCheckpoints('scratch')); // A file of your own, e.g. one per CI job. const perJob = new Kosa8Client({ checkpointStore: new FileCheckpointStore('/tmp/k8d/job-42-checkpoints.json') }); console.log('per-job store:', perJob.checkpoints.store.path); // The snapshot behind a checkpoint is an ordinary snapshot; remove both with the sandbox. await isolated.deleteSandbox('scratch').then((r) => console.log(r)); ``` ``` $ node ckptstore.mjs default store: /tmp/k8d/.kosa8/checkpoints.json memory store has: [ 'scratch-ckpt-1' ] file store has: [] per-job store: /tmp/k8d/job-42-checkpoints.json { ok: true, checkpoints_dropped: 1 } ``` Any object with `load()` and `update(fn)` can be a store (`CheckpointStore`), for example one backed by your database. ## Things worth knowing - **Ten per sandbox.** The eleventh checkpoint evicts the oldest (`MAX_CHECKPOINTS_PER_SANDBOX`), deleting its snapshot only after the new one exists. `evicted` lists what went. (Not exercised for this page: eleven snapshots would not fit the test machine's disk.) - **A checkpoint costs memory-sized disk.** It saves the guest's memory as well as cloning its disks. In our runs a 1 GiB sandbox checkpointed in about 0.65 s (656 ms in the quickstart, 647 ms in the migration recipe). - **A rewind destroys the current state** and cannot itself be undone. Take a checkpoint first if you might want the current state back. - **If the restore step fails**, the sandbox has already been removed. The error is a `Kosa8Error` whose message ends with the `restoreSnapshot(…)` call that brings it back, because the checkpoint's snapshot is still there. - **On a Mac, the screen must be unlocked** for a rewind. Taking a checkpoint works while it is locked; restoring it does not. From kosa8 0.9.36 the error says "macOS blocks restoring a VM while the screen is locked; unlock and retry". See [Troubleshooting](https://docs.kosa8.com/guides/js-troubleshooting/#locked). - **Same instant, two writers.** The SDK serialises its own updates and those of other Node processes with a lock file. The Go tools lock the same file differently, so an SDK update and a CLI update in the same instant could lose one record. The snapshots are unaffected. - **Under signed approvals**, a rewind makes two guarded calls (delete, then restore) with the same `approval` id, and one approval covers one call. See [Approvals](https://docs.kosa8.com/guides/js-approvals/#composite). **A complete recipe** [Checkpoint before a migration, then rewind](https://docs.kosa8.com/guides/js-recipes/#migration): a SQLite database, a migration with a bug, and the rewind that saves the data. --- Source: https://docs.kosa8.com/guides/js-checkpoints/ # Fork and explore A fork copies a running sandbox into live, independent copies, each resuming from the same instant with its processes intact. `explore` builds best-of-N on top: one fork per approach, all run at the same time, and the fastest one that succeeds is kept. ## Fork ``` import { Kosa8Client } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); await kosa8.createSandbox({ name: 'base', egress: 'deny' }); await kosa8.pullIntoSandbox('base', 'alpine:latest'); // The slow part, done once, in a long-lived container (a file stands in for installed dependencies). const cid = await kosa8.startContainer('base', 'alpine:latest', ['sh', '-c', 'echo "deps installed" > /state; while :; do sleep 3600; done']); const f = await kosa8.fork('base', { n: 3, prefix: 'try' }); console.log(`forked ${f.count} copies in ${f.fork_ms}ms from snapshot ${f.snapshot}`); for (const s of f.forks) console.log(' ', s.id, s.state, 'parent', s.parent); // The container came along, running, under the same id. Each copy then goes its own way. await kosa8.exec('try1', cid, ['sh', '-c', 'echo "changed in try1" > /state']); for (const id of ['base', 'try1', 'try2']) { console.log(id, '->', (await kosa8.exec(id, cid, ['cat', '/state'])).stdout.trim()); } for (const s of f.forks) await kosa8.deleteSandbox(s.id); ``` ``` $ node fork.mjs forked 3 copies in 2165ms from snapshot base-fork-1792111473462173000 try1 running parent base try2 running parent base try3 running parent base base -> deps installed try1 -> changed in try1 try2 -> deps installed ``` The copies are named `1`, `2`, … (the prefix defaults to `-fork`), and each records its `parent`. The long-lived container came along in every copy under the same id, already running, so the slow setup was paid for once. A change in one copy touches neither the others nor the parent. | Option | Meaning | |---|---| | `n` | How many copies. Default 1. | | `prefix` | Name prefix for the copies. | | `label` | Raise the copies' classification label above the parent's. | The result is `{ forks, snapshot, fork_ms, count }`. `snapshot` is the temporary snapshot the copies were restored from; after a successful fork it was gone from `listSnapshots()`. ## Explore: best of N Continuing with `base` from above: ``` import { Kosa8Client } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); // Three ways to do the same job. Each runs as `sh -c VARIANT` in a fresh container from // `image`, inside its own fork of `base`, all at the same time. const res = await kosa8.explore('base', { image: 'alpine:latest', variants: [ 'sleep 2; echo "slow but fine"', 'echo "quick and fine"', 'echo "this approach is broken" >&2; exit 1', ], timeoutMs: 30_000, prefix: 'attempt', }); for (const b of res.branches) { console.log(b.name === res.winner ? '*' : ' ', b.name, `exit ${b.exit}`, `${b.duration_ms}ms`, JSON.stringify(b.output), b.error ?? ''); } console.log('winner:', res.winner, '| removed:', res.removed); console.log('sandboxes now:', (await kosa8.listSandboxes()).map((s) => s.id)); await kosa8.deleteSandbox(res.winner); ``` ``` $ node explore.mjs attempt1 exit 0 2454ms "slow but fine" * attempt2 exit 0 438ms "quick and fine" attempt3 exit 1 455ms "this approach is broken" winner: attempt2 | removed: 2 sandboxes now: [ 'base', 'attempt2' ] ``` `explore` does what `kosa8 explore` does: it pulls `image` into the parent (so every branch has it), forks once per variant, runs `sh -c VARIANT` in a fresh container from `image` in each branch at the same time, and keeps the fastest branch that exited 0. The others are deleted. The winner is a full sandbox you can keep working in, or delete. | Option | Default | Meaning | |---|---|---| | `variants` | (required) | Shell commands, one per branch. At least two. | | `image` | `alpine:latest` | The image each variant runs in. | | `pull` | `true` | Pull `image` into the parent first. Set `false` if it is already there. | | `keepAll` | `false` | Keep every branch, not only the winner. | | `keep` | | Keep this branch (by id) instead of the winner. It must be one of the branches, so you need to know the prefix. | | `timeoutMs` | 120000 | Per-branch limit. A branch that runs over is recorded with an `error`. | | `prefix` | `-explore-` | Branch names are `1`, `2`, …. | Each branch in `branches` is `{ name, variant, exit, output, duration_ms, error? }`, where `output` is stdout and stderr together. `winner` is `""` when every branch failed. **Each variant runs in a new container** The variant runs in a fresh container from `image`, inside the fork. It inherits the fork's mounts, images and network rule, but not files that other containers wrote: those are in their own containers' filesystems. Put shared setup in the image, or on a read-only mount, as the [parallel test variants recipe](https://docs.kosa8.com/guides/js-recipes/#variants) does. A timed-out branch's process is not killed by the time limit (the request is abandoned); it goes when the branch is deleted. ## Sizing Each copy is a whole machine with the parent's memory size. Disks are copy-on-write clones and cost little until written, but memory is real: four copies of a 1 GiB sandbox need 4 GiB more, and on a Mac that pushes into swap, which lives on the same disk. In our tests a four-way explore of 1 GiB sandboxes on a nearly full disk failed partway; the same recipe with three 512 MiB sandboxes ran in 1.4 s per branch. Make the parent as small as the work allows before you fork it. **A fork that fails partway leaves debris** If copy 3 of 3 fails, copies 1 and 2 and the fork's snapshot (`-fork-…`) are left behind, and `explore` rejects without cleaning up. List them with `listSandboxes()` and `listSnapshots()` and delete them. In v0.9.10 the failed copy's name could also stay reserved until the daemon restarted ("sandbox tz3 already exists"), so use a new `prefix` for the retry. Fixed in kosa8 0.9.36: a fork keeps all of its copies or none. A failed copy removes the ones already made and the fork's snapshot, releases every name, and the error ends "(nothing was kept: …)". A name that is taken fails the fork with a `Kosa8ConflictError` before anything is snapshotted. Fork, explore and rewind restore saved machines, so on a Mac they need the screen unlocked; see [Troubleshooting](https://docs.kosa8.com/guides/js-troubleshooting/#locked). **A complete recipe** [Parallel test variants](https://docs.kosa8.com/guides/js-recipes/#variants): one test suite run under three timezones at once, from a read-only mount. --- Source: https://docs.kosa8.com/guides/js-fork-explore/ # Snapshots A snapshot is a saved running machine with a name you choose: its memory, its processes and its disks. Restoring one boots a new sandbox that resumes where the snapshot was taken. Checkpoints, forks and the AI SDK adapter's stop and resume are all built on snapshots. ## Take one ``` import { Kosa8Client } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); // Something worth saving: a running container with state in memory and on disk. const cid = await kosa8.startContainer('runner', 'alpine:latest', ['sh', '-c', 'echo "started $(date +%s)" > /boot.txt; i=0; while :; do i=$((i+1)); echo $i > /counter; sleep 1; done']); await new Promise((r) => setTimeout(r, 3000)); const s = await kosa8.snapshot('runner', { id: 'runner-before-upgrade' }); console.log(`saved ${s.snapshot.id} in ${s.snapshot_ms}ms,`, (s.snapshot.size_bytes / 2 ** 20).toFixed(0), 'MiB of machine state'); ``` ``` $ node snap1.mjs saved runner-before-upgrade in 598ms, 80 MiB of machine state ``` The sandbox pauses for the save and carries on afterwards. The result is `{ snapshot, snapshot_ms, encrypted_by_policy?, encrypt_ms?, encryption? }`, and `snapshot.size_bytes` is the saved memory state. The disks are copy-on-write clones, so they cost little until either side writes. Leave `id` out and the daemon names it `-snap-`. A taken id is refused with a 409. ## Restore it, as often as you like ``` import { Kosa8Client } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); await kosa8.createSandbox({ name: 'worker', egress: 'deny', memMb: 512 }); await kosa8.pullIntoSandbox('worker', 'alpine:latest'); const cid = await kosa8.startContainer('worker', 'alpine:latest', ['sh', '-c', 'i=0; while :; do i=$((i+1)); echo $i > /tick; sleep 1; done']); await new Promise((r) => setTimeout(r, 3000)); const { snapshot } = await kosa8.snapshot('worker', { id: 'worker-golden' }); console.log('saved', snapshot.id, '| tick was', (await kosa8.exec('worker', cid, ['cat', '/tick'])).stdout.trim()); // Restore as a new sandbox beside the original. Its processes resume where they were. const r = await kosa8.restoreSnapshot('worker-golden', { name: 'worker-copy' }); console.log(`restored ${r.sandbox.id} in ${r.restore_ms}ms, state ${r.sandbox.state}`); console.log('copy tick:', (await kosa8.exec('worker-copy', cid, ['cat', '/tick'])).stdout.trim()); // A snapshot can be restored as often as you like; delete it when done. await kosa8.deleteSandbox('worker-copy'); await kosa8.deleteSandbox('worker'); console.log(await kosa8.deleteSnapshot('worker-golden')); ``` ``` $ node restore.mjs saved worker-golden | tick was 4 restored worker-copy in 482ms, state running copy tick: 4 { encrypted: false, ok: true, removed: 'worker-golden' } ``` The restored sandbox's counter reads 4, the value it had when the snapshot was taken: the process resumed, it did not start again. The container id from the original works in the copy. `restoreSnapshot` takes: | Option | Meaning | |---|---| | `name` | Name of the new sandbox. Must not be in use. | | `label` | Raise the restored sandbox's classification label. | | `allowTampered` | Restore a *sealed* snapshot whose seal does not verify. The restore is recorded in the audit log as such. Without it, a broken seal refuses the restore. Not exercised for this page. | A snapshot keeps the egress rule of the sandbox it was taken from, so the restored copy has the same network. A brokered sandbox is the exception: its secrets were never saved, so it comes back with no broker and a closed network. ## List and delete ``` import { Kosa8Client, Kosa8ConflictError } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); for (const s of await kosa8.listSnapshots()) { console.log(s.id, '| sandbox', s.sandbox, '|', s.cpus, 'cpus', s.mem_mb, 'MiB |', (s.size_bytes / 2 ** 20).toFixed(0), 'MiB state | egress', s.egress); } // Ids are unique: taking a second snapshot under a used id is refused. try { await kosa8.snapshot('runner', { id: 'runner-before-upgrade' }); } catch (err) { console.log(err.constructor.name, err.status, '-', err.message); } console.log(await kosa8.deleteSnapshot('runner-before-upgrade')); ``` ``` $ node snap2.mjs runner-before-upgrade | sandbox runner | 2 cpus 1024 MiB | 80 MiB state | egress deny Kosa8ConflictError 409 - snapshot already exists: runner-before-upgrade (choose another id, or delete the existing snapshot first) { encrypted: false, ok: true, removed: 'runner-before-upgrade' } ``` `deleteSnapshot(id, { wipe?, force? })` takes the same `wipe` as `deleteSandbox`, overwriting the files first. `force` is needed to delete a *sealed* snapshot, which is otherwise refused with a 409 that says so. ## Encrypt `snapshot(id, { encrypt: true })` encrypts the snapshot's memory and disks at rest, with a key kept on this machine, and then overwrites the plaintext. An org policy can require it for every snapshot, in which case the result reports `encrypted_by_policy`. Deleting an encrypted snapshot destroys its data key (crypto-shredding), so the ciphertext left on the SSD is useless. ``` const e = await kosa8.snapshot('runner', { id: 'runner-encrypted', encrypt: true }); console.log(`saved ${e.snapshot.id} encrypted in ${e.snapshot_ms}ms + ${e.encrypt_ms}ms:`, e.encryption); ``` On the test machine this call failed, with about 3.6 GB free on the disk: ``` Kosa8ApiError: snapshot runner-encrypted was taken but not encrypted: encrypted, but the plaintext of disk.img could not be wiped; run `kosa8 snapshot encrypt runner-encrypted` again (encrypt it with `kosa8 snapshot encrypt runner-encrypted`, or delete it with `kosa8 snapshot rm --wipe runner-encrypted`) ``` Encryption writes an encrypted copy of each disk, 1.5 GiB for the guest disk alone, and then overwrites the plaintext. On a copy-on-write filesystem such as APFS the overwrite is written to new blocks, so the operation needs free space of more than twice the guest disk while it runs. The disk had dropped to under 200 MB by the time it gave up. The snapshot was left unencrypted and the error said so, with the two ways out. Deleting it reported the key destroyed: ``` $ kosa8 snapshot rm runner-encrypted --force snapshot runner-encrypted removed data key destroyed (crypto-shred, KEK 33ff32188e87d8ca) ``` untested A successful `encrypt: true` was not run for this page, for lack of disk space. ## Snapshots and checkpoints #### Snapshot You name it. It lives until you delete it, independent of the sandbox. Restore it under a new name, as many times as you like: a golden image of a running machine. #### Checkpoint A snapshot with an automatic id and a label, on a per-sandbox stack of at most ten. `rewind` restores it under the *same* name. Deleted with the sandbox. **Next** [Approvals and policy](https://docs.kosa8.com/guides/js-approvals/): when an org policy wants a person to sign off first. --- Source: https://docs.kosa8.com/guides/js-snapshots/ # Approvals and policy An organisation can require that named people sign off before certain actions run: deleting a sandbox, opening the network, sharing a host folder. When a call needs that, the SDK throws `Kosa8ApprovalRequiredError` with everything a person needs to approve it. Your code waits for the approval id, then repeats the call with it. The policy is signed and installed by an administrator; see [Company rules](https://docs.kosa8.com/guides/governance/) and the [Policy reference](https://docs.kosa8.com/guides/policy-reference/). Without one installed, nothing on this page applies, and every call runs without asking. ## The policy used on this page One rule: any destructive action needs one signed approval from someone other than the person asking. Two people are listed. The requester must be listed too; see [below] for why. ``` { "name": "sdk-docs-test", "approvers": [ {"name": "Asha Rao", "email": "asha@example.com", "public_key": "8yU/pivJ5lS5yO4Qt4xJ7nL9SIiarRoHGy4/v+z6UyY=", "roles": ["security"]}, {"name": "Dev Requester", "email": "dev@example.com", "public_key": "J1mlnrtJI2Z/tfplWtx2dvBx50nwyKa1S1eyHnUQEsA="} ], "approval_rules": [ {"match": "destructive", "required": 1} ] } ``` ``` $ kosa8 policy sign org.key profile.json > signed.json $ kosa8 policy trust $(cat org.key.pub) trusted policy signer set $ kosa8 policy install signed.json installed and activated policy "sdk-docs-test" ``` It took effect at once, without a daemon restart. ## 1. Your code meets the refusal ``` import { Kosa8Client, Kosa8ApprovalRequiredError } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); try { await kosa8.deleteSandbox('runner'); } catch (err) { if (!(err instanceof Kosa8ApprovalRequiredError)) throw err; const a = err.approval; console.log('status', err.status, '|', err.message); console.log('why:', a.why); console.log('class:', a.class, '| action:', a.action); console.log('signers:', a.signers); console.log('ask for it with:', a.request_command); console.log('requirement:', JSON.stringify(a.requirement)); } ``` ``` $ node approve1.mjs status 403 | policy "sdk-docs-test" requires 1 signed approval(s) for destructive, from people other than the requester: `sandbox-rm runner` needs signed approval before it can run — no approval was presented with the call (ask for it with `kosa8 approve request sandbox-rm runner --reason "..."`; have one of Asha Rao sign it with `kosa8 approve sign REQUEST --reason "..."` (as many distinct people as the policy asks, never the requester); file what they send back with `kosa8 approve add FILE...` and record it with `kosa8 approve status ID --record`; then repeat this call with the approval id — `--approval ID` on the CLI, the `approval` argument of an MCP tool, or the X-Kosa8-Approval header on the API) why: no approval was presented with the call class: destructive | action: { kind: 'sandbox-rm', target: 'runner' } signers: [ 'Asha Rao ' ] ask for it with: kosa8 approve request sandbox-rm runner requirement: {"policy":"sdk-docs-test","rules":[{"match":"destructive","required":1}]} ``` Nothing happened to the sandbox. `err.approval` is the daemon's refusal, unchanged: | Field | Meaning | |---|---| | `why` | What was wrong: nothing presented, not recorded, already used, expired. | | `action` | `{ kind, target?, command?, sandbox? }`: exactly what is being approved. | | `class` | `read-only`, `contained`, `destructive` or `escalating`. | | `signers` | The people the policy lets approve this. | | `request_command` | The CLI command that opens a request for exactly this action. | | `requirement` | `{ policy, rules }`: which rules apply. | | `policy`, `policy_digest`, `route`, `description`, `request_id` | Which policy, its hash, the API route, a description, and the approval id that was presented, if any. | In an app, show `why`, `signers` and `request_command` to the person using it, or post them to wherever approvals happen in your team. ## 2. A person asks, and an approver signs This part is done by people, with the CLI. The requester opens a request with their signing identity (`kosa8 identity init` makes one): ``` $ kosa8 approve request --reason "tear down the CI sandbox" sandbox-rm runner request 5a259f1f403447c43278d2e35bf9741c opened by Dev Requester [cdf5aa01c24ec6c5] action sandbox-rm runner on runner needs policy "sdk-docs-test" requires 1 signed approval(s) for destructive, from people other than the requester expires Thu, 15 Oct 2026 21:41:34 EDT file /tmp/k8d/.kosa8/approvals/5a259f1f403447c43278d2e35bf9741c/request.json send the file to an approver; they run: kosa8 approve sign /tmp/k8d/.kosa8/approvals/5a259f1f403447c43278d2e35bf9741c/request.json --reason "..." an approver on this machine can pass the request id instead of the file. add what they send back with `kosa8 approve add FILE`, then check with kosa8 approve status 5a259f1f4034 ``` **Put `--reason` before the action** Everything after the action kind is read as part of the action. The refusal's hint, and the first request we opened, put `--reason "…"` at the end: `kosa8 approve request sandbox-rm runner --reason "…"`. That recorded the action as `sandbox-rm runner --reason '…'`, which is not the call your code will make. Write the flags first, as above. Fixed in kosa8 0.9.36: the hint reads `kosa8 approve request --reason "..." sandbox-rm runner`, and `approve request` takes a `--reason` written at the very end as the reason. An action that really ends in `--reason` goes after `--`. The approver reviews exactly what they are signing and signs with their own identity. For this page the approver's identity was in a separate folder on the same machine, hence `--identity`; normally they run this on their own computer with the request file you sent them. `-y` skips the confirmation prompt and `-o` writes the approval to a file to send back: ``` $ kosa8 approve sign request.json --identity ./asha --reason "reviewed: CI sandbox, nothing to keep" -y -o asha-approval.json sandbox-rm runner on runner touches the kosa8 daemon on this host touches target runner risk destructive requested by Dev Requester [cdf5aa01c24ec6c5] at Thu, 15 Oct 2026 20:41:34 EDT on … their why tear down the CI sandbox policy sdk-docs-test (sha256:c12afa84ace502265e2c5be09cf610d186c207b567284a7251a72cd8a66e494a) signing as Asha Rao [4c34c0c35fb8fdd2] approved request 5a259f1f4034 as Asha Rao [4c34c0c35fb8fdd2] wrote /tmp/k8d/.kosa8/approvals/5a259f1f403447c43278d2e35bf9741c/approval-4c34c0c35fb8fdd2.json wrote asha-approval.json send that file back to the requester; they add it with `kosa8 approve add FILE` ``` That request was opened under our first version of the policy, which turned out to need two approvers (see [Who can ask]). After the policy was fixed, a new request, `3ec6fc09ef23`, was opened and signed the same way. The requester files the approval and records it: ``` $ kosa8 approve add asha-approval.json added approval by Asha Rao [4c34c0c35fb8fdd2] for request 3ec6fc09ef23 $ kosa8 approve status 3ec6fc09ef23 --record approved under policy "sdk-docs-test" by Asha Rao recorded in the audit log run the action once with `--approval 3ec6fc09ef23b948f14f942658490a63` (MCP: approval=3ec6fc09ef23b948f14f942658490a63) ``` ## 3. Your code repeats the call with the id ``` import { Kosa8Client, Kosa8ApprovalRequiredError } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); const approval = process.argv[2]; // the id `kosa8 approve status ID --record` printed console.log(await kosa8.deleteSandbox('runner', { approval })); // An approval covers one call, once. Presenting it again is refused. await kosa8.createSandbox({ name: 'runner', egress: 'deny' }); try { await kosa8.deleteSandbox('runner', { approval }); } catch (err) { if (err instanceof Kosa8ApprovalRequiredError) console.log('again:', err.approval.why); else throw err; } ``` ``` $ node approve2.mjs 3ec6fc09ef23b948f14f942658490a63 { ok: true, checkpoints_dropped: 0 } again: approval 3ec6fc09ef23 was already used; one approval authorizes one action once — open a new request ``` Every method takes `approval` as an option; the SDK sends it as the `X-Kosa8-Approval` header. The approval was spent by that one call. The same id on the next call, for the same action on a new sandbox of the same name, was refused. ## Who can ask Our first attempt listed only the approver and set `allow_unlisted_requesters: true`. The approval was then not enough: ``` kosa8: not yet approved under policy "sdk-docs-test" (requested by Dev Requester [cdf5aa01c24ec6c5]) destructive needs 2 distinct approver(s): have 1 (Asha Rao ), still need 1 more — and nobody left in the policy can supply it; an administrator must add approvers ``` That is deliberate. A request from a key the policy does not list could be a listed approver under a throwaway key, so kosa8 asks for at least two distinct listed approvers on every rule. List every requester in `approvers`, as the policy above does, and one approval from someone else is enough. The requester never counts towards their own request. ## Calls that make several requests An approval covers one request. Some SDK methods make several guarded requests, all with the `approval` you pass: | Method | Requests it makes | |---|---| | `deleteSandbox` | The delete, then one snapshot delete per checkpoint of that sandbox. | | `rewind` | A sandbox delete, a snapshot restore, then a snapshot delete per dropped checkpoint. | | `checkpoint` | A snapshot, then a snapshot delete per evicted checkpoint. | | `explore` | A pull, a fork, one run per branch, one delete per losing branch. | | AI SDK adapter `stop()`, `destroy()` | Snapshot and stop; delete sandbox and snapshot. | If the policy covers more than one of those requests, the first spends the approval and the next is refused. The SDK swallows refusals on the clean-up steps (old snapshots stay behind) but not on the main ones. For those, call the underlying methods yourself, one approval each, or ask your administrator to scope the rule to what actually needs a person. ## Other refusals by policy A policy can also refuse outright, with no approval possible: an image from a registry not on `allowed_registries`, a mount outside a label's `mount_paths`, the shared engine under `require_sandbox`. Those arrive as `Kosa8PolicyError` (HTTP 403) with the daemon's reason in `message` and what to do in `remediation`. When we were done, we uninstalled the test policy by moving `~/.kosa8/policy.json` and `~/.kosa8/policy-trust.pub` out of the test state directory. On a real machine, policy is managed by your administrator. **Next** [Errors reference](https://docs.kosa8.com/guides/js-errors/): every error class, when it is thrown, and what to do. --- Source: https://docs.kosa8.com/guides/js-approvals/ # Errors reference Every error the SDK throws is a `Kosa8Error`, and an error from the daemon keeps the daemon's own words: what went wrong, and what to do about it. This page lists each class with a real message from kosa8 v0.9.10. ## The hierarchy ``` Error └─ Kosa8Error anything this SDK throws; also client-side checks ├─ Kosa8ConnectionError the daemon could not be reached ├─ Kosa8StreamError a run, exec or attach ended with an error frame ├─ Kosa8CheckpointError checkpoint bookkeeping: none to rewind to, bad name └─ Kosa8ApiError the daemon answered with an HTTP error ├─ Kosa8BadRequestError 400 ├─ Kosa8AuthError 401 ├─ Kosa8PolicyError 403 │ └─ Kosa8ApprovalRequiredError 403 with a signed-approval refusal ├─ Kosa8NotFoundError 404 ├─ Kosa8ConflictError 409 └─ Kosa8UnsupportedError 501 ``` Any other status, such as 500, is a plain `Kosa8ApiError`. All classes are exported, so `instanceof` works, and `err.name` is the class name. ## Fields | Field | On | Meaning | |---|---|---| | `message` | all | For a daemon error: `" ()"`, the way the CLI prints it. | | `status` | daemon errors | The HTTP status. | | `daemonMessage` | daemon errors, stream errors | The daemon's `error` text, verbatim. | | `remediation` | daemon errors | The daemon's advice: what to do next. | | `method`, `path` | daemon and connection errors | The request that failed, such as `POST /kosa8/v1/pull`. | | `body` | daemon errors | The decoded response body. | | `cause` | connection errors, failed rewinds | The underlying error, such as Node's `ENOENT`. | | `stdout`, `stderr` | `Kosa8StreamError` | Output produced before the error. | | `approval` | `Kosa8ApprovalRequiredError` | The daemon's refusal: `why`, `signers`, `request_command`, `action`, `class`, `requirement`, … | ``` import { Kosa8Client, Kosa8Error, Kosa8PolicyError, Kosa8CheckpointError } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); const show = (err) => console.log(`${err.constructor.name} status=${err.status}\n daemonMessage: ${err.daemonMessage}\n remediation: ${err.remediation}\n ${err.method} ${err.path}`); // 403 from the org policy (here: allowed_registries is ["ghcr.io"]). await kosa8.pull('docker.io/library/alpine:latest').catch((err) => { console.log(err instanceof Kosa8PolicyError, err instanceof Kosa8Error); show(err); }); // Client-side checkpoint bookkeeping. await kosa8.rewind('never-checkpointed').catch((err) => console.log(err.constructor.name + ':', err.message)); await kosa8.checkpoint('../etc').catch((err) => console.log(err.constructor.name + ':', err.message)); ``` ``` $ node errors.mjs true true Kosa8PolicyError status=403 daemonMessage: policy "sdk-docs-registries" forbids registry "docker.io" (allowed: ghcr.io) remediation: the org policy restricts which registries may be pulled; see `kosa8 policy show` POST /kosa8/v1/pull Kosa8CheckpointError: no checkpoints for "never-checkpointed" — take one with checkpoint() or `kosa8 checkpoint never-checkpointed` Kosa8CheckpointError: invalid sandbox name "../etc" ``` (For that run a test policy with `"allowed_registries": ["ghcr.io"]` was installed, then removed.) ## Each class ### `Kosa8ConnectionError` **When:** the request never reached a daemon: no socket file, nothing listening, a socket path too long for the operating system, a remote host that does not answer. ``` kosa8 daemon unreachable at unix:///tmp/k8d/no-daemon-here.sock: connect ENOENT /tmp/k8d/no-daemon-here.sock (is kosa8 installed and its daemon running? start it with `kosa8 daemon start`) ``` **Handle:** start the daemon (`kosa8 daemon start`) or fix the address. `err.cause.code` is Node's error code: `ENOENT` (no socket), `ECONNREFUSED` (socket, no daemon), `EINVAL` (path too long on macOS). Safe to retry once the daemon is up; nothing was done. ### `Kosa8BadRequestError` (400) **When:** the daemon refused a value, most often an egress rule. ``` egress allow "*.com": com is a public suffix, under which anyone can register a name, so the wildcard would admit strangers' hosts; allow a domain below it, as in *.example.com ``` **Handle:** fix the input; retrying the same request fails the same way. The message says what is wrong with it. ### `Kosa8AuthError` (401) **When:** an agent token was presented and is not valid, or a remote worker's bearer token is missing or wrong. A bad agent token is never treated as anonymous. ``` agent token is not recognised; mint one with `kosa8 agent token create NAME` missing or invalid bearer token (set KOSA8_TOKEN to the worker's token) ``` **Handle:** mint a new agent token, or set `KOSA8_TOKEN` / `token`. See [Connecting](https://docs.kosa8.com/guides/js-connecting/). ### `Kosa8PolicyError` (403) **When:** the org policy forbids the action outright: a registry not on the allow-list, a label rule, `require_sandbox`. Example above. **Handle:** do something the policy allows, or ask whoever manages the policy. `kosa8 policy show` prints the active policy. ### `Kosa8ApprovalRequiredError` (403) **When:** the policy wants named people to approve this exact action, and the call brought no usable approval. A subclass of `Kosa8PolicyError`, so catch it first. ``` why: no approval was presented with the call signers: [ 'Asha Rao ' ] ask for it with: kosa8 approve request sandbox-rm runner ``` **Handle:** show `err.approval` to a person, wait for the approval id, then repeat the call with `{ approval: id }`. The whole flow is on [Approvals and policy](https://docs.kosa8.com/guides/js-approvals/). ### `Kosa8NotFoundError` (404) **When:** no such sandbox or snapshot. ``` no such sandbox: ro (list sandboxes with `kosa8 sandbox ls`) ``` **Handle:** usually ignore it on clean-up paths (the thing is already gone). Note that `getSandbox` returns `undefined` instead of throwing. A container that does not exist is reported differently, as a stream error (below). ### `Kosa8ConflictError` (409) **When:** the request conflicts with current state: a snapshot id already taken, a sealed snapshot deleted without `force`. ``` snapshot already exists: runner-before-upgrade (choose another id, or delete the existing snapshot first) ``` **Handle:** choose another id, or resolve the state first. A sandbox name that is taken comes back as 500 in v0.9.10, not 409 (below). From kosa8 0.9.36 it is a 409 too, for create, restore and fork: ``` sandbox ro already exists (choose another name, or remove the existing sandbox with `kosa8 sandbox rm NAME` (`kosa8 sandbox ls` lists them)) ``` ### `Kosa8UnsupportedError` (501) **When:** the machine cannot do this: no microVM backend (sandboxes need macOS on Apple silicon, or Linux with KVM), or a guest image from an older release that lacks a feature, in which case the message names the fix. Not reproduced for this page, which ran on a supported machine with a matching guest. **Handle:** run on a supported machine; `kosa8 doctor` says what is missing. ### `Kosa8ApiError` (other statuses) **When:** any other HTTP error, usually 500. ``` sandbox ro already exists (run `kosa8 doctor`; check ~/.kosa8/log/) restore machine state: Error Domain=VZErrorDomain Code=12 Description="An error occurred while restoring the virtual machine. The virtual machine failed to restore with error “permission denied”." … (list snapshots with `kosa8 snapshot ls`) ``` **Handle:** read the message. The second one means the Mac's screen is locked; see [Troubleshooting](https://docs.kosa8.com/guides/js-troubleshooting/#locked). Both are v0.9.10 messages: from kosa8 0.9.36 the first is a `Kosa8ConflictError` (409), and the second is a 503 that begins "macOS blocks restoring a VM while the screen is locked; unlock and retry". The daemon's log, `~/.kosa8/log/kosa8d.log`, has more. ### `Kosa8StreamError` **When:** a `run`, `exec` or `attach` started streaming and then ended with an error instead of an exit code, or the stream ended with no final frame. ``` image "docker.io/library/python:3.13-alpine" not found in engine: image "docker.io/library/python:3.13-alpine": not found no such container: k99-nope failed to create shim task: OCI runtime create failed: executable file `python3` not found in $PATH: No such file or directory ``` **Handle:** pull the image first, check the container id, or check the command exists in the image. `err.stdout` and `err.stderr` hold any output produced before the failure. A command that ran and exited non-zero is *not* an error: `exitCode` is in the result. ### `Kosa8CheckpointError` **When:** checkpoint bookkeeping, done in the client: nothing to rewind to, a checkpoint `to` that matches no id or label, a sandbox name that could escape the store, a store lock not acquired within 10 seconds. Examples above. **Handle:** take a checkpoint first, or list them with `listCheckpoints`. ### `Kosa8Error` (itself) **When:** a check in the client, before anything is sent, or a composite operation that failed partway. ``` egressAllow already limits the network to the hosts it lists; do not also set egress an egress allow-list needs at least one host; use egress: "deny" for no network refusing to talk to 10.0.0.5:7440 over plain http: the token and every request would cross the network unencrypted. Use https://10.0.0.5:7440 (a worker behind a TLS-terminating tunnel or proxy), or set insecure: true / KOSA8_INSECURE=1 if this link is already private and trusted ``` Other messages of this class in the source: `give at least two variants; with one there is nothing to compare` (explore), `keep … is not one of the branches` (explore), and `restore failed and NAME is now stopped: … — recover with restoreSnapshot(…)` (rewind), whose `cause` is the daemon's error. In that last case the sandbox has already been removed and the checkpoint's snapshot is intact. ## A handler that covers the cases ``` import { Kosa8ApprovalRequiredError, Kosa8PolicyError, Kosa8ConnectionError, Kosa8NotFoundError, Kosa8StreamError, Kosa8Error, } from '@kosa8/sdk'; try { // ... a kosa8 call ... } catch (err) { if (err instanceof Kosa8ApprovalRequiredError) { // ask a person; retry later with { approval: id } notifyApprovers(err.approval.why, err.approval.signers, err.approval.request_command); } else if (err instanceof Kosa8PolicyError) { // refused by policy, no approval possible } else if (err instanceof Kosa8ConnectionError) { // daemon down: start it, or fail fast } else if (err instanceof Kosa8NotFoundError) { // already gone } else if (err instanceof Kosa8StreamError) { // the command could not run; err.stdout / err.stderr hold partial output } else if (err instanceof Kosa8Error) { console.error(err.message); // includes the daemon's remediation } else { throw err; // not from kosa8 (an AbortSignal reason, for example) } } ``` That block is a pattern, not a program: `notifyApprovers` is yours to write. Note that an aborted call rejects with the signal's reason (a `TimeoutError` or `AbortError`), not a `Kosa8Error`. --- Source: https://docs.kosa8.com/guides/js-errors/ # Vercel AI SDK 7 adapter `@kosa8/ai-sdk-sandbox` gives an AI SDK agent a sealed machine to work in. Its tools read and write files and run commands through the sandbox they are handed, and the machine has its own kernel and a network closed to everything you did not allow. It implements the AI SDK's own sandbox interfaces, the same shape as `@ai-sdk/sandbox-vercel` and `@e2b/ai-sdk-sandbox`, and runs on your machine with no account. ``` $ npm install @kosa8/ai-sdk-sandbox ai ``` Node 22 or later, and kosa8 running; see [Install](https://docs.kosa8.com/guides/js-install/). Tested with `ai@7.0.142`. ## Concepts **Session**One kosa8 sandbox. Creating a session boots a microVM; destroying it deletes the microVM. `session.sandboxName` is its name, so you can also drive it with `@kosa8/sdk` or the CLI. **Work container**Inside the sandbox, one long-lived container from `image` (default `alpine:latest`) runs every command and holds every file. The image is pulled through the host, so this works under `deny-all`. Its working directory is `/workspace` unless you say otherwise; relative paths resolve against it. **restricted()**A frozen object with only `description`, the six read and write methods, `run` and `spawn`. Hand this to tools and to the model's side of your code: it cannot reach the client, stop the sandbox or change its network. **Template**Setup run once and saved as a kosa8 snapshot. Later sessions with the same template restore the snapshot instead of repeating the setup, processes included. ## Create a session, and use it ``` import { createKosa8NetworkSandboxSession } from '@kosa8/ai-sdk-sandbox'; const session = await createKosa8NetworkSandboxSession({ image: 'alpine:latest', // the work container's image (the default) networkPolicy: { mode: 'deny-all' }, env: { APP_ENV: 'test' }, }); console.log('sandbox', session.sandboxName, 'container', session.containerId, 'cwd', session.defaultWorkingDirectory); console.log(session.description); // The tool-safe view: files, run and spawn. No stop, destroy or network controls. const sandbox = session.restricted(); console.log('restricted keys:', Object.keys(sandbox), 'frozen:', Object.isFrozen(sandbox)); await sandbox.writeTextFile({ path: 'notes/todo.txt', content: 'one\ntwo\nthree\n' }); console.log(await sandbox.readTextFile({ path: 'notes/todo.txt', startLine: 2, endLine: 3 })); console.log('missing ->', await sandbox.readTextFile({ path: 'nope.txt' })); console.log(await sandbox.run({ command: 'pwd; echo $APP_ENV $EXTRA; ls notes', env: { EXTRA: 'per-call' } })); console.log(await sandbox.run({ command: 'pwd', workingDirectory: '/tmp' })); console.log(await sandbox.run({ command: 'echo to-stderr >&2; exit 2' })); await session.destroy(); console.log('destroyed'); process.exit(0); // see Known issues: 0.1.0 can leave a socket open after run() ``` ``` $ node ad1.mjs sandbox sbx6 container k1-ad24a82f cwd /workspace kosa8 sandbox "sbx6": a microVM with its own kernel. Commands run in a alpine:latest container inside it (container k1-ad24a82f). Working directory: /workspace. Relative paths resolve against it. Network: no network access (every outbound connection is refused). Filesystem changes persist for the lifetime of the sandbox. No ports are exposed to the host. restricted keys: [ 'description', 'readFile', 'readBinaryFile', 'readTextFile', 'writeFile', 'writeBinaryFile', 'writeTextFile', 'spawn', 'run' ] frozen: true two three missing -> null { exitCode: 0, stdout: '/workspace\ntest per-call\ntodo.txt\n', stderr: '' } { exitCode: 0, stdout: '/tmp\n', stderr: '' } { exitCode: 2, stdout: '', stderr: 'to-stderr\n' } destroyed ``` `description` is written for the model: put it in the prompt so the model knows what machine it has. `run` applies the session's `env` and working directory to each command; per-call `env` and `workingDirectory` win. A non-zero exit is in the result, not thrown. File methods are covered on [Files](https://docs.kosa8.com/guides/js-files/). ### Settings | Setting | Default | Meaning | |---|---|---| | `client` | `new Kosa8Client()` | A `Kosa8Client`, or its options (socket, host, actor, agent token…). | | `image` | `alpine:latest` | The work container's image. It needs `sh` and `base64`. | | `workingDirectory` | `/workspace` | Created if missing. | | `env` | | Environment for every command. | | `networkPolicy` | daemon default | The AI SDK's policy. Mapped to kosa8 egress below. | | `egress` | | kosa8's own egress instead (`'deny'`, `'allow:…'`, `{ allow }`…). Not with `networkPolicy`. | | `cpus`, `memMb`, `diskGb` | daemon defaults | Machine size. | | `mounts` | | Host folders, `{ host_path, guest_path, read_only }`; they appear in the work container. | | `labels`, `broker` | | As for `createSandbox`. With `broker`, read the placeholders through `@kosa8/sdk`; the session does not return them. | | `setupCommands` | | Shell commands run once on a fresh sandbox. A failure deletes the sandbox and throws. | | `approval` | | A signed-approval id sent with every call ([Approvals](https://docs.kosa8.com/guides/js-approvals/)). | | `sandboxId` | daemon picks | The sandbox's name. Creating never resumes: a taken name is an error. | | `template` | | `{ identity, prepare }`. Below. | | `abortSignal` | | Cancels creation. | ## Network policy ``` import { createKosa8NetworkSandboxSession, toKosa8Egress } from '@kosa8/ai-sdk-sandbox'; // How AI SDK network policies map onto kosa8 egress. for (const p of [ { mode: 'allow-all' }, { mode: 'deny-all' }, { mode: 'custom', allowedHosts: ['registry.npmjs.org', '*.githubusercontent.com'], allowedCIDRs: ['10.20.0.0/16'] }, { mode: 'custom', allowedHosts: ['example.com'], deniedCIDRs: ['10.0.0.0/8'] }, ]) { try { console.log(JSON.stringify(p), '->', toKosa8Egress(p)); } catch (e) { console.log(JSON.stringify(p), '->', e.name + ':', e.message); } } const session = await createKosa8NetworkSandboxSession({ networkPolicy: { mode: 'custom', allowedHosts: ['example.com'] }, }); try { await session.setNetworkPolicy({ mode: 'custom', allowedHosts: ['example.com'] }); console.log('same policy again: ok (no-op)'); for (const [what, fn] of [ ['setNetworkPolicy(allow-all)', () => session.setNetworkPolicy({ mode: 'allow-all' })], ['getPortEndpoint(3000)', () => session.getPortEndpoint({ port: 3000 })], ['setPorts([3000])', () => session.setPorts([3000])], ]) { try { await fn(); } catch (e) { console.log(`${what} -> ${e.name}: ${e.message}`); } } console.log('ports:', session.ports); } finally { await session.destroy(); } ``` ``` $ node ad3.mjs {"mode":"allow-all"} -> any {"mode":"deny-all"} -> deny {"mode":"custom","allowedHosts":["registry.npmjs.org","*.githubusercontent.com"],"allowedCIDRs":["10.20.0.0/16"]} -> allow:registry.npmjs.org,*.githubusercontent.com,10.20.0.0/16 {"mode":"custom","allowedHosts":["example.com"],"deniedCIDRs":["10.0.0.0/8"]} -> AI_HarnessCapabilityUnsupportedError: kosa8 egress is an allow-list with no deny-list: every host or network not in allowedHosts/allowedCIDRs is already refused on the host. Remove deniedCIDRs (and leave those networks out of allowedCIDRs). same policy again: ok (no-op) setNetworkPolicy(allow-all) -> AI_HarnessCapabilityUnsupportedError: kosa8 fixes a sandbox's egress policy when the sandbox is created, and cannot change it while it runs (sandbox "sbx6" has "allow:example.com"; asked for "any"). Create the session with this networkPolicy instead. getPortEndpoint(3000) -> AI_HarnessCapabilityUnsupportedError: kosa8 does not expose sandbox ports to the host through its API, so there is no endpoint to connect to. Use a harness adapter that does not need a bridge port, or talk to the process through run/spawn. setPorts([3000]) -> AI_HarnessCapabilityUnsupportedError: kosa8 cannot expose sandbox ports (3000) to the host through its API. ports: [] ``` | AI SDK policy | kosa8 egress | |---|---| | `{ mode: 'allow-all' }` | `any` | | `{ mode: 'deny-all' }` | `deny`: every outbound connection refused | | `{ mode: 'custom', allowedHosts, allowedCIDRs }` | `allow:` those hosts and CIDRs, `*.domain` wildcards included; everything else refused on the host | | `deniedCIDRs` | refused: kosa8 has an allow-list, not a deny-list | | neither set | the org policy's `default_egress`, else `any` | An unsupported request throws the AI SDK's `HarnessCapabilityUnsupportedError`, never a silent no-op, so a harness that can fall back will. ## spawn, kill and abort ``` import { createKosa8NetworkSandboxSession } from '@kosa8/ai-sdk-sandbox'; const session = await createKosa8NetworkSandboxSession({ networkPolicy: { mode: 'deny-all' } }); const sandbox = session.restricted(); try { // spawn: returns at once, with output as web streams. const t0 = Date.now(); const proc = await sandbox.spawn({ command: 'for i in 1 2 3; do echo line $i; sleep 1; done' }); const dec = new TextDecoder(); for await (const chunk of proc.stdout) process.stdout.write(`[${Date.now() - t0}ms] ${dec.decode(chunk)}`); console.log('exit', (await proc.wait()).exitCode); // kill(): TERM to the command and everything it started, then KILL after 2s. const long = await sandbox.spawn({ command: 'sleep 300 & sleep 300; echo never' }); setTimeout(() => long.kill(), 1000); console.log('killed ->', await long.wait()); // abortSignal does the same, and wait() rejects with the signal's reason. try { await sandbox.run({ command: 'sleep 300', abortSignal: AbortSignal.timeout(1500) }); } catch (err) { console.log('aborted ->', err.name); } console.log('left running:', (await sandbox.run({ command: 'ps -o pid,args | grep -c "[s]leep 300"' })).stdout.trim()); } finally { await session.destroy(); } process.exit(0); // see Known issues: 0.1.0 leaves a socket open after kill() or an abort ``` ``` $ node ad2.mjs [5ms] line 1 [1011ms] line 2 [2013ms] line 3 exit 0 killed -> { exitCode: 143 } aborted -> TimeoutError left running: 0 ``` `spawn` returns at once with `stdout` and `stderr` as web streams, `wait()` and `kill()`. Unlike an SDK-level abort, the adapter's `kill()` and `abortSignal` end the command inside the sandbox and everything it started: TERM, then KILL after two seconds. The last line confirms no `sleep 300` was left behind. There is no stdin and no `pid`. ## Stop, resume, destroy ``` import { createKosa8NetworkSandboxSession, resumeKosa8NetworkSandboxSession, } from '@kosa8/ai-sdk-sandbox'; // Session 1: do some work, then stop. const s1 = await createKosa8NetworkSandboxSession({ sandboxId: 'agent-42', networkPolicy: { mode: 'deny-all' } }); await s1.restricted().writeTextFile({ path: 'progress.md', content: '- [x] step 1\n' }); // PID 1 of the work container is its long-lived main process; ps shows how long it has run. const pid1 = 'ps -o pid,etime,args | sed -n 2p'; await new Promise((r) => setTimeout(r, 2000)); console.log('before stop: ', (await s1.run({ command: pid1 })).stdout.trim()); await s1.stop(); // snapshot of the running machine, then the sandbox stops console.log('stopped; sandbox state:', (await s1.restricted().run({ command: 'true' }).catch((e) => e.message))); // Later, maybe from another process: resume by name. Files and processes come back. const s2 = await resumeKosa8NetworkSandboxSession({ sandboxId: 'agent-42' }); console.log('resumed', s2.sandboxName, 'same container:', s2.containerId === s1.containerId); console.log(await s2.restricted().readTextFile({ path: 'progress.md' })); console.log('after resume:', (await s2.run({ command: pid1 })).stdout.trim()); await s2.destroy(); console.log('destroyed'); process.exit(0); // see "Known issue" below: 0.1.0 can leave a socket open after run() ``` ``` $ node ad4.mjs before stop: 1 0:03 sh -c touch /.kosa8-ai-sdk 2>/dev/null; mkdir -p '/workspace' 2>/dev/null; while :; do sleep 3600; done stopped; sandbox state: sandbox is not running: agent-42 (start it with `kosa8 sandbox up agent-42`) resumed agent-42 same container: true - [x] step 1 after resume: 1 0:03 sh -c touch /.kosa8-ai-sdk 2>/dev/null; mkdir -p '/workspace' 2>/dev/null; while :; do sleep 3600; done destroyed ``` - **`stop()`** snapshots the running machine as `-aisdk-resume`, then stops it. - **`resumeKosa8NetworkSandboxSession({ sandboxId })`** restores that snapshot under the same name and deletes it. The work container is the same container, its PID 1 never restarted: it reads the same elapsed time because it was paused, not stopped. If the sandbox was stopped some other way, resume starts it from its disk and a new work container. If it is running, resume reattaches. It never creates a sandbox: a name that does not exist rejects with `Kosa8NotFoundError`. - **`destroy()`** deletes the sandbox and any resume snapshot. - **Background processes do not survive the command that started them.** In testing, a process started with `&` (or `setsid`) inside `run` was gone once that command returned. Long-lived processes belong in the image's entry point, or in a sandbox you drive with `@kosa8/sdk`'s `startContainer`. ## Templates: set up once ``` import { createKosa8NetworkSandboxSession, templateSnapshotId } from '@kosa8/ai-sdk-sandbox'; import { Kosa8Client } from '@kosa8/sdk'; const template = { identity: 'py-tools-v1', // change it when prepare() changes prepare: async ({ session }) => { // Expensive setup, done once. Here: a fake 3-second install that leaves a file behind. await session.run({ command: 'sleep 3; mkdir -p /opt/tools && echo "tools v1" > /opt/tools/VERSION' }); }, }; for (const n of [1, 2]) { const t0 = Date.now(); const s = await createKosa8NetworkSandboxSession({ networkPolicy: { mode: 'deny-all' }, template }); const v = (await s.run({ command: 'cat /opt/tools/VERSION' })).stdout.trim(); console.log(`session ${n}: ${s.sandboxName} ready in ${Date.now() - t0}ms, ${v}`); await s.destroy(); } // The template lives on as a kosa8 snapshot until you delete it. const id = templateSnapshotId('py-tools-v1', 'alpine:latest', 'deny', '/workspace'); const kosa8 = new Kosa8Client(); console.log('template snapshot:', (await kosa8.listSnapshots()).map((s) => s.id), 'expected', id); console.log(await kosa8.deleteSnapshot(id)); process.exit(0); ``` ``` $ node ad5.mjs session 1: sbx1 ready in 7462ms, tools v1 session 2: aisdk-fb74ec19d4 ready in 475ms, tools v1 template snapshot: [ 'aisdk-tpl-8b0b0400732fb898644fb54b' ] expected aisdk-tpl-8b0b0400732fb898644fb54b { encrypted: false, ok: true, removed: 'aisdk-tpl-8b0b0400732fb898644fb54b' } ``` The first session ran `prepare` and saved the result; the second restored it in under half a second. The snapshot id is a hash of the identity, image, egress and working directory (`templateSnapshotId`), so changing any of them gives a new template. Change `identity` when `prepare` changes. The snapshot stays until you delete it. ## A full agent with generateText ``` import { generateText, stepCountIs, tool } from 'ai'; import { MockLanguageModelV4 } from 'ai/test'; import { z } from 'zod'; import { createKosa8NetworkSandboxSession } from '@kosa8/ai-sdk-sandbox'; // Tools act only through the sandbox the AI SDK hands them (experimental_sandbox). const tools = { writeFile: tool({ description: 'Write a text file in the sandbox.', inputSchema: z.object({ path: z.string(), content: z.string() }), execute: async ({ path, content }, { experimental_sandbox: sandbox, abortSignal }) => { await sandbox.writeTextFile({ path, content, abortSignal }); return { written: path }; }, }), shell: tool({ description: 'Run a shell command in the sandbox.', inputSchema: z.object({ command: z.string() }), execute: ({ command }, { experimental_sandbox: sandbox, abortSignal }) => sandbox.run({ command, abortSignal }), }), }; // A scripted model so this runs without an API key. Swap in any AI SDK model: // import { anthropic } from '@ai-sdk/anthropic'; const model = anthropic('claude-sonnet-4-5'); const usage = { inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined }, outputTokens: { total: 1, text: 1, reasoning: undefined }, }; const call = (id, toolName, input) => ({ content: [{ type: 'tool-call', toolCallId: id, toolName, input: JSON.stringify(input) }], finishReason: { unified: 'tool-calls', raw: undefined }, usage, warnings: [], }); const model = new MockLanguageModelV4({ doGenerate: [ call('c1', 'writeFile', { path: 'fib.sh', content: 'a=0; b=1; for i in $(seq 10); do echo $a; t=$((a+b)); a=$b; b=$t; done\n' }), call('c2', 'shell', { command: 'sh fib.sh | tr "\\n" " "; echo; wget -q -T 3 -O- https://example.com >/dev/null || echo "network: refused"' }), { content: [{ type: 'text', text: 'Wrote fib.sh and ran it; the network is closed.' }], finishReason: { unified: 'stop', raw: undefined }, usage, warnings: [] }, ], }); const session = await createKosa8NetworkSandboxSession({ networkPolicy: { mode: 'deny-all' } }); try { const sandbox = session.restricted(); const result = await generateText({ model, tools, stopWhen: stepCountIs(5), prompt: `Write a script that prints the first ten Fibonacci numbers and run it.\n\nSandbox:\n${sandbox.description}`, experimental_sandbox: sandbox, }); for (const step of result.steps) { for (const r of step.toolResults) console.log(`${r.toolName} ->`, JSON.stringify(r.output)); } console.log(result.text); } finally { await session.destroy(); } // 0.1.0 can leave the exec socket open after run(); see Known issues. Exit explicitly. process.exit(0); ``` ``` $ node agent.mjs writeFile -> {"written":"fib.sh"} shell -> {"exitCode":0,"stdout":"0 1 1 2 3 5 8 13 21 34 \nnetwork: refused\n","stderr":"wget: bad address 'example.com'\n"} Wrote fib.sh and ran it; the network is closed. ``` The model here is the AI SDK's `MockLanguageModelV4`, scripted to make the tool calls a real model would, so the example runs without an API key. Everything else is real: the tool calls ran in a kosa8 microVM, and the network refusal came from the host. To use a real model, replace `model` with any AI SDK provider's model. untested No hosted model was used for this page. The same `tools` work anywhere the AI SDK accepts tools, such as `streamText`. ## HarnessAgent The adapter also implements the harness interfaces, `HarnessV1NetworkSandboxSession` and `HarnessV1SandboxProvider`: ``` // a session const session = await createKosa8NetworkSandboxSession({ image: 'node:22-alpine' }); const agentSession = await agent.createSession({ sandboxSession: session }); // or the older provider shape new HarnessAgent({ sandbox: createKosa8Sandbox({ networkPolicy: { mode: 'deny-all' } }) }); ``` untested `HarnessAgent` itself was not run for this page. The provider object it would call was: ``` import { createKosa8Sandbox, sandboxNameForSession } from '@kosa8/ai-sdk-sandbox'; // The provider shape (HarnessAgent({ sandbox })): a sessionId maps to a fixed sandbox name. const provider = createKosa8Sandbox({ networkPolicy: { mode: 'deny-all' } }); console.log(provider.providerId, provider.specificationVersion, '| name for "chat-7":', sandboxNameForSession('chat-7')); const a = await provider.createSession({ sessionId: 'chat-7' }); await a.restricted().writeTextFile({ path: 'memory.txt', content: 'user prefers tabs' }); await a.stop(); const b = await provider.resumeSession({ sessionId: 'chat-7' }); console.log(b.id, '->', await b.restricted().readTextFile({ path: 'memory.txt' })); await b.destroy(); process.exit(0); ``` ``` $ node ad6.mjs kosa8-sandbox harness-sandbox-v1 | name for "chat-7": aisdk-chat-7-b4362fbc75 aisdk-chat-7-b4362fbc75 -> user prefers tabs ``` A `sessionId` maps to a fixed sandbox name (`sandboxNameForSession`), so `resumeSession` finds the same machine. `identity` plus `onFirstCreate` in `createSession` work like a template. ## Wrapping a sandbox you manage ``` import { Kosa8Client } from '@kosa8/sdk'; import { createKosa8NetworkSandboxSessionFromSandbox, createKosa8SandboxSessionFromContainer, } from '@kosa8/ai-sdk-sandbox'; // A sandbox your own code manages, with whatever options the adapter does not expose. const kosa8 = new Kosa8Client(); await kosa8.createSandbox({ name: 'mine', egress: 'deny', memMb: 512, labels: { owner: 'ci' } }); // Wrap it. With no container given, the adapter starts its work container in it. const session = await createKosa8NetworkSandboxSessionFromSandbox({ client: kosa8, sandbox: 'mine' }); console.log((await session.run({ command: 'echo wrapped; pwd' })).stdout.trim()); await session.destroy(); // does nothing: the caller owns this sandbox console.log('still there:', (await kosa8.getSandbox('mine'))?.state); // Only the tool-safe view, over a container you started yourself. const cid = await kosa8.startContainer('mine', 'alpine:latest', ['sh', '-c', 'mkdir -p /srv; while :; do sleep 3600; done']); const tools = createKosa8SandboxSessionFromContainer({ client: kosa8, sandbox: 'mine', container: cid, workingDirectory: '/srv' }); await tools.writeTextFile({ path: 'x.txt', content: 'ok' }); console.log((await tools.run({ command: 'ls /srv; cat x.txt' })).stdout.trim().replace(/\n/g, ' | ')); console.log('has stop?', 'stop' in tools, '| keys:', Object.keys(tools).length); await kosa8.deleteSandbox('mine'); process.exit(0); ``` ``` $ node ad7.mjs wrapped /workspace still there: running x.txt | ok has stop? false | keys: 9 ``` `createKosa8NetworkSandboxSessionFromSandbox` wraps a sandbox you created, and its `stop()` and `destroy()` do nothing. `createKosa8SandboxSessionFromContainer` returns only the restricted view, over a container you started. ## Limits #### What the adapter cannot do - **`setNetworkPolicy()`** on a running session: only a no-op for the policy already in force. Anything else throws. - **`deniedCIDRs`**: throws. List what is allowed instead. - **Ports**: `getPortEndpoint` and `setPorts([…])` throw; `ports` is `[]`. - **Request transformations** (`setRequestTransformations`, `addRequestTransformations`): not provided. Use the `broker` setting to keep credentials out, configured at creation. - **`spawn`**: no stdin, no `pid`. - **Files** move through exec, so the image needs `sh` and `base64`. - **Checkpoints and fork** are not part of the AI SDK interface. Use `@kosa8/sdk` on `session.sandboxName`. **Known issue in 0.1.0: a script may not exit** After `run`, `spawn`, `kill()` or an abort, version 0.1.0 can leave the exec request's socket open, so Node keeps running after your last line. In our tests the agent example and the spawn example hung after printing everything, and the first example on this page exited on its first run but hung on the next two. That is why several examples here end with `process.exit(0)`. The cause is that the adapter stops reading the exec stream at its final frame without closing it; one added `await iter.return()` in `spawn` fixed it in a local test. Until a fixed release, end scripts with `process.exit()`. A long-running server is not blocked by it, but would hold one open socket per affected command. Fixed in `@kosa8/ai-sdk-sandbox` 0.1.1, which closes the stream after its final frame. **Recipe** [A coding agent editing a repository](https://docs.kosa8.com/guides/js-recipes/#coding-agent) through a mounted folder, with `git diff` to review its work. --- Source: https://docs.kosa8.com/guides/js-ai-sdk/ # Recipes Each recipe is a whole program, run against kosa8 v0.9.10 as shown, with its real output. Copy one, change the parts that are yours, and run it. [`Run LLM-generated code safely`No network, a time limit that works, a fresh container per run.] [`A coding agent on your repo`One mounted folder, reviewed with git diff.] [`Parallel test variants`One suite, three timezones, at the same time.] [`Checkpoint before a migration`A bad migration, then the rewind that saves the data.] [`An API route that runs user code`Express and Next.js.] [`CI on GitHub Actions`A Linux runner with KVM. Untested.] [`MCP from TypeScript frameworks`AI SDK, LangChain.js, Mastra, Claude Agent SDK.] [`dockerode and Testcontainers`Existing Docker code, unchanged, via DOCKER_HOST.] ## Run LLM-generated code safely A model wrote some Python and you want to run it. The code might be wrong, slow, or hostile. Give it a machine with no network, a small memory, a fresh container for every run, a time limit enforced inside the sandbox, and a cap on how much output you keep. ``` import { Kosa8Client } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); const IMAGE = 'python:3.13-alpine'; // One sealed machine for untrusted code: no network, small, its own kernel. await kosa8.createSandbox({ name: 'untrusted', egress: 'deny', memMb: 512 }); await kosa8.pullIntoSandbox('untrusted', IMAGE); async function runUntrusted(code, { seconds = 10, maxOutput = 64 * 1024 } = {}) { // Each call is a fresh container: nothing one snippet writes is seen by the next. // `timeout` inside the sandbox ends the process; aborting the request would not. // The trailing `; exit $?` keeps sh as PID 1, so python is an ordinary child that // SIGKILL can reach (PID 1 in a container ignores signals it has no handler for). const r = await kosa8.run('untrusted', IMAGE, ['sh', '-c', 'timeout -s KILL "$0" python3 -c "$1"; exit $?', String(seconds), code]); return { exitCode: r.exitCode, timedOut: r.exitCode === 137, stdout: r.stdout.slice(0, maxOutput), stderr: r.stderr.slice(0, maxOutput), }; } // Pretend each of these came back from a model. const generated = { fine: 'print(sum(i * i for i in range(10)))', network: 'import urllib.request\nurllib.request.urlopen("https://example.com", timeout=5)', hostFiles: 'import os\nprint(os.path.exists("/Users"), os.path.exists("/etc/passwd"), os.uname().nodename)', forever: 'while True: pass', crash: 'raise SystemExit(3)', }; for (const [name, code] of Object.entries(generated)) { const r = await runUntrusted(code, { seconds: 8 }); console.log(name.padEnd(9), JSON.stringify({ ...r, stderr: r.stderr.trim().split('\n').pop() })); } await kosa8.deleteSandbox('untrusted'); ``` ``` $ node llmcode.mjs fine {"exitCode":0,"timedOut":false,"stdout":"285\n","stderr":""} network {"exitCode":1,"timedOut":false,"stdout":"","stderr":"urllib.error.URLError: "} hostFiles {"exitCode":0,"timedOut":false,"stdout":"False True kosa8-guest\n","stderr":""} forever {"exitCode":137,"timedOut":true,"stdout":"","stderr":"Killed"} crash {"exitCode":3,"timedOut":false,"stdout":"","stderr":""} ``` The network attempt failed to resolve. The code saw a Linux machine called `kosa8-guest` with no `/Users`: none of your files exist there. The infinite loop was killed at 8 seconds. Every outcome is an exit code your program can act on. **The time limit, and PID 1** Two things went wrong on the way to this version, and both will bite you elsewhere. Aborting the HTTP request does not stop the code, so the limit has to be inside the sandbox. And the first version, `timeout 8 python3 -c …` as the container's command, never fired: `timeout` replaces itself with Python, which becomes PID 1, and PID 1 in a container ignores signals it has no handler for, even SIGKILL sent from inside. A plain `sh -c '… python3 …'` was no better, because `sh` runs its last command in place of itself. The trailing `; exit $?` keeps `sh` as PID 1 and Python as an ordinary child, and then the limit works. ## A coding agent editing your repo through a mounted folder The agent's machine sees one folder of yours, writable, and nothing else. It edits files there; you review the result with `git diff` on your side, where it cannot reach. ``` import { generateText, stepCountIs, tool } from 'ai'; import { MockLanguageModelV4 } from 'ai/test'; import { z } from 'zod'; import { createKosa8NetworkSandboxSession } from '@kosa8/ai-sdk-sandbox'; const repo = `${process.env.HOME}/projects/greeter`; // a git checkout on your machine // The agent's machine sees this one folder, writable, at /workspace/repo, and nothing else. const session = await createKosa8NetworkSandboxSession({ image: 'alpine:latest', networkPolicy: { mode: 'deny-all' }, mounts: [{ host_path: repo, guest_path: '/workspace/repo', read_only: false }], workingDirectory: '/workspace/repo', }); const tools = { readFile: tool({ description: 'Read a file.', inputSchema: z.object({ path: z.string() }), execute: async ({ path }, { experimental_sandbox: sb }) => ({ content: await sb.readTextFile({ path }) }), }), writeFile: tool({ description: 'Replace a file.', inputSchema: z.object({ path: z.string(), content: z.string() }), execute: async ({ path, content }, { experimental_sandbox: sb }) => { await sb.writeTextFile({ path, content }); return { ok: true }; }, }), shell: tool({ description: 'Run a shell command in the repository.', inputSchema: z.object({ command: z.string() }), execute: ({ command }, { experimental_sandbox: sb }) => sb.run({ command }), }), }; // Scripted so it runs without a key; a real model makes the same tool calls. const usage = { inputTokens: { total: 1, noCache: 1 }, outputTokens: { total: 1, text: 1 } }; const call = (id, toolName, input) => ({ content: [{ type: 'tool-call', toolCallId: id, toolName, input: JSON.stringify(input) }], finishReason: { unified: 'tool-calls' }, usage, warnings: [], }); const model = new MockLanguageModelV4({ doGenerate: [ call('1', 'readFile', { path: 'greet.js' }), call('2', 'writeFile', { path: 'greet.js', content: "export function greet(name = 'world') {\n return `Hello, ${name}!`;\n}\n" }), call('3', 'shell', { command: 'ls -la; cat /etc/hostname; ls /Users 2>&1 | head -1' }), { content: [{ type: 'text', text: 'greet() now has a default and an exclamation mark.' }], finishReason: { unified: 'stop' }, usage, warnings: [] }, ] }); try { const r = await generateText({ model, tools, stopWhen: stepCountIs(6), prompt: 'Give greet() a default name.', experimental_sandbox: session.restricted(), }); console.log(r.steps.flatMap((s) => s.toolResults).at(-1).output.stdout); console.log(r.text); } finally { await session.destroy(); } process.exit(0); ``` ``` $ node coding-agent.mjs total 4 drwxr-xr-x 4 root root 128 Oct 16 00:57 . drwxr-xr-x 12 root root 384 Oct 16 00:57 .git -rw-r--r-- 1 root root 70 Oct 16 00:57 greet.js localhost ls: /Users: No such file or directory greet() now has a default and an exclamation mark. $ git -C ~/projects/greeter diff diff --git a/greet.js b/greet.js index aeaa36f..b950a75 100644 --- a/greet.js +++ b/greet.js @@ -1,3 +1,3 @@ -export function greet(name) { - return 'Hello, ' + name; +export function greet(name = 'world') { + return `Hello, ${name}!`; } ``` The model is scripted so the recipe runs without a key; replace it with a real one and the tools are unchanged. Add a [checkpoint](https://docs.kosa8.com/guides/js-checkpoints/) before each step, or give the agent [a broker route](https://docs.kosa8.com/guides/js-network/#broker) to its model API instead of `deny-all`. ### When the agent chooses the folder: `--allow-mount` If the agent creates its own sandboxes through MCP, you decide which folders it may share, on the command line of `kosa8 mcp serve`. Anything outside is refused: ``` import { createMCPClient } from '@ai-sdk/mcp'; import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio'; const HOME = process.env.HOME; // The agent may share host folders into the sandboxes it creates only below --allow-mount. const mcp = await createMCPClient({ transport: new Experimental_StdioMCPTransport({ command: 'kosa8', args: ['mcp', 'serve', '--allow-mount', `${HOME}/projects:rw`], env: { HOME, PATH: process.env.PATH }, }), }); const opts = { toolCallId: 't', messages: [] }; const text = (r) => r.content.map((c) => c.text).join('').trim(); try { const tools = await mcp.tools(); const p = tools.sandbox_create.inputSchema.jsonSchema.properties; console.log('mount:', p.mount.description, '\nread_write:', p.read_write.description); for (const mount of [`${HOME}/projects/greeter:/work`, `${HOME}/app:/work`]) { const r = await tools.sandbox_create.execute({ name: 'mcp-mount', egress: 'deny', mount, read_write: true }, opts); console.log(mount.replace(HOME, '~'), '->', r.isError ? 'refused: ' + text(r) : 'created'); if (!r.isError) await tools.sandbox_delete.execute({ sandbox: 'mcp-mount' }, opts); } } finally { await mcp.close(); } ``` ``` $ node mcp-mount.mjs mount: host directory to share, as HOST:GUEST (read-only), or HOST:GUEST:rw; HOST must be absolute and inside a directory the user allowed with --allow-mount read_write: share the mount writable; refused unless the user allowed its directory with :rw ~/projects/greeter:/work -> created ~/app:/work -> refused: mount host path "/tmp/k8d/app" is outside the directories this MCP server allows (--allow-mount) ``` ## Parallel test variants with explore Run one test suite under several configurations at once, each in its own fork, and read every result. This project has a timezone bug: ``` // Formats a timestamp as the local calendar date. export const day = (ms) => new Date(ms).toDateString(); ``` ``` import { test } from 'node:test'; import assert from 'node:assert'; import { day } from '../index.js'; test('a late-evening UTC timestamp', () => { assert.strictEqual(day(Date.UTC(2026, 9, 15, 22, 30)), 'Thu Oct 15 2026'); }); ``` ``` import { Kosa8Client } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); const IMAGE = 'node:22-alpine'; // The project, read-only: every fork sees the same files and none can change them. await kosa8.createSandbox({ name: 'ci', egress: 'deny', memMb: 512, // every fork gets the same size: keep it small mounts: [{ host_path: `${process.env.HOME}/projects/dates`, guest_path: '/src', read_only: true }], }); await kosa8.pullIntoSandbox('ci', IMAGE); // Does the suite pass in every timezone we ship to? One fork per timezone, in parallel. const zones = ['UTC', 'America/Los_Angeles', 'Asia/Kolkata']; const res = await kosa8.explore('ci', { image: IMAGE, pull: false, // already pulled above variants: zones.map((tz) => `cd /src && TZ=${tz} node --test 2>&1 | grep -E "^# (pass|fail)"`), keepAll: true, // keep every branch: we want all the results, not a winner prefix: 'zone', }); res.branches.forEach((b, i) => { const [pass, fail] = b.output.split('\n').map((l) => l.split(' ').pop()); console.log(zones[i].padEnd(17), b.name, `pass ${pass} fail ${fail}`, `${b.duration_ms}ms`); }); for (const b of res.branches) await kosa8.deleteSandbox(b.name); await kosa8.deleteSandbox('ci'); ``` ``` $ node variants.mjs UTC zone1 pass 1 fail 0 1415ms America/Los_Angeles zone2 pass 1 fail 0 1450ms Asia/Kolkata zone3 pass 0 fail 1 1433ms ``` Asia/Kolkata is five and a half hours ahead of UTC, so 22:30 UTC on 15 October is already 16 October there, and the test fails. Three forks ran at once, each about 1.4 seconds. The project is on a read-only mount, so every fork sees it and none can change it. Keep the parent small (`memMb: 512` here): every fork is a whole machine of the same size. See [Fork and explore](https://docs.kosa8.com/guides/js-fork-explore/). ## Checkpoint before a migration, then rewind ``` import { Kosa8Client } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); const IMAGE = 'python:3.13-alpine'; const app = kosa8.sandbox('app'); await kosa8.createSandbox({ name: 'app', egress: 'deny' }); await app.pull(IMAGE); const cid = await app.startContainer(IMAGE, ['sh', '-c', 'while :; do sleep 3600; done']); const py = async (code) => { const r = await app.exec(cid, ['python3', '-c', code]); if (r.exitCode !== 0) throw new Error(r.stderr.trim()); return r.stdout.trim(); }; const count = () => py('import sqlite3; print(sqlite3.connect("/data/app.db").execute("select count(*) from users").fetchone()[0])'); // Some data worth protecting. await py(`import os, sqlite3 os.makedirs("/data", exist_ok=True) db = sqlite3.connect("/data/app.db") db.execute("create table users (id integer primary key, email text, plan text)") db.executemany("insert into users (email, plan) values (?, ?)", [(f"u{i}@example.com", "free" if i % 3 else "pro") for i in range(1000)]) db.commit()`); console.log('users before:', await count()); // 1. Checkpoint right before the risky step. const cp = await app.checkpoint({ label: 'before 0042_drop_free_users' }); console.log(`checkpoint ${cp.checkpoint.id} in ${cp.snapshot.snapshot_ms}ms`); // 2. The migration has a bug: the WHERE clause is wrong. await py(`import sqlite3 db = sqlite3.connect("/data/app.db") db.execute("delete from users where plan != 'enterprise'") db.commit()`); const after = Number(await count()); console.log('users after the migration:', after); // 3. A check fails, so rewind. Same sandbox name, same container, data back. if (after < 900) { const rw = await app.rewind(); console.log(`rewound to ${rw.checkpoint.id} in ${rw.restore_ms}ms`); console.log('users now:', await count()); } await app.delete(); ``` ``` $ node migrate.mjs users before: 1000 checkpoint app-ckpt-1 in 647ms users after the migration: 0 rewound to app-ckpt-1 in 532ms users now: 1000 ``` The migration meant to delete free users and deleted everyone. The check after it failed, so the program rewound: half a second later the same sandbox, with the same running container, had all 1,000 rows. No backup was restored and nothing was rebuilt. Because a checkpoint holds the whole machine, a database server running in the container would come back with its processes as well; this recipe used SQLite, and a server database was not tested here. ## An API route that runs user code ### Express ``` import express from 'express'; import { Kosa8Client, Kosa8Error } from '@kosa8/sdk'; const kosa8 = new Kosa8Client(); const SANDBOX = 'api-runner'; const IMAGE = 'python:3.13-alpine'; // One sealed machine for the server's lifetime. Each request gets a fresh container in it. if (!(await kosa8.getSandbox(SANDBOX))) { await kosa8.createSandbox({ name: SANDBOX, egress: 'deny', memMb: 1024 }); } await kosa8.pullIntoSandbox(SANDBOX, IMAGE); const app = express(); app.use(express.json({ limit: '64kb' })); app.post('/run', async (req, res) => { const code = req.body?.code; if (typeof code !== 'string' || code.length === 0) return res.status(400).json({ error: 'code is required' }); try { const r = await kosa8.run(SANDBOX, IMAGE, ['sh', '-c', 'timeout -s KILL 10 python3 -c "$0"; exit $?', code]); res.json({ exitCode: r.exitCode, stdout: r.stdout.slice(0, 65536), stderr: r.stderr.slice(0, 65536) }); } catch (err) { // A daemon refusal or an unreachable daemon: say so, and keep the daemon's own words. const status = err instanceof Kosa8Error && err.status === 403 ? 403 : 502; res.status(status).json({ error: err.message }); } }); const server = app.listen(3000, () => console.log('listening on http://localhost:3000')); for (const sig of ['SIGINT', 'SIGTERM']) { process.on(sig, async () => { server.close(); await kosa8.deleteSandbox(SANDBOX).catch(() => {}); process.exit(0); }); } ``` ``` $ node server.mjs listening on http://localhost:3000 $ curl -s localhost:3000/run -H 'content-type: application/json' -d '{"code":"import sys; print(sys.version.split()[0]); print(2**100)"}' {"exitCode":0,"stdout":"3.13.16\n1267650600228229401496703205376\n","stderr":""} $ curl -s localhost:3000/run -H 'content-type: application/json' -d '{"code":"open(\"/tmp/x\",\"w\").write(\"hi\"); import socket; socket.create_connection((\"1.1.1.1\", 443), timeout=3)"}' {"exitCode":1,"stdout":"","stderr":"Traceback (most recent call last):\n File \"\", line 1, in \n open(\"/tmp/x\",\"w\").write(\"hi\"); import socket; socket.create_connection((\"1.1.1.1\", 443), timeout=3)\n ~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.13/socket.py\", line 868, in create_connection\n raise exceptions[0]\n File \"/usr/local/lib/python3.13/socket.py\", line 853, in create_connection\n sock.connect(sa)\n ~~~~~~~~~~~~^^^^\nConnectionRefusedError: [Errno 111] Connection refused\n"} $ curl -s -o /dev/null -w "%{http_code}\n" localhost:3000/run -H 'content-type: application/json' -d '{}' 400 ``` Ctrl-C (SIGINT) closed the server and deleted the sandbox: afterwards only an unrelated sandbox was listed. One sandbox serves every request here and each request gets a fresh container, so one request's files are never seen by the next. For users who must not share a kernel, create a sandbox per user or per session and delete it when they leave. ### Next.js (App Router) ``` import { Kosa8Client } from '@kosa8/sdk'; // The SDK speaks to a unix socket with node:http, so this route must run on Node, not the Edge runtime. export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; const kosa8 = new Kosa8Client(); const SANDBOX = 'next-runner'; const IMAGE = 'python:3.13-alpine'; // Created on first use and reused by later requests in this server process. let ready; function ensureSandbox() { ready ??= (async () => { if (!(await kosa8.getSandbox(SANDBOX))) { await kosa8.createSandbox({ name: SANDBOX, egress: 'deny', memMb: 1024 }); } await kosa8.pullIntoSandbox(SANDBOX, IMAGE); })().catch((err) => { ready = undefined; throw err; }); return ready; } export async function POST(request) { const { code } = await request.json().catch(() => ({})); if (typeof code !== 'string' || !code) return Response.json({ error: 'code is required' }, { status: 400 }); try { await ensureSandbox(); const r = await kosa8.run(SANDBOX, IMAGE, ['sh', '-c', 'timeout -s KILL 10 python3 -c "$0"; exit $?', code]); return Response.json({ exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr }); } catch (err) { return Response.json({ error: err.message }, { status: err.status === 403 ? 403 : 502 }); } } ``` ``` $ npx next build Route (app) ┌ ○ /_not-found └ ƒ /api/run $ npx next start -p 3001 ✓ Ready in 134ms $ curl -s localhost:3001/api/run -H 'content-type: application/json' -d '{"code":"import platform; print(platform.machine(), platform.release())"}' {"exitCode":0,"stdout":"aarch64 6.18.35\n","stderr":""} $ curl -s localhost:3001/api/run -H 'content-type: application/json' -d '{"code":"print(1/0)"}' {"exitCode":1,"stdout":"","stderr":"Traceback (most recent call last):\n File \"\", line 1, in \n print(1/0)\n ~^~\nZeroDivisionError: division by zero\n"} ``` Tested with Next.js 16.4.2 and React 19.3.1, with `"type": "module"` in `package.json`. The first request took 10.6 seconds, because it created the sandbox and pulled the image; later requests reuse both. Two requirements: `runtime = 'nodejs'`, because the SDK opens a unix socket with `node:http`, which the Edge runtime does not have; and a server that runs on the same machine as kosa8 (your own server or VM, not a serverless platform). ## CI on GitHub Actions (Linux) **untested Not run for this release** This workflow was not run. It is assembled from what the installer checks on Linux (KVM access, `cloud-hypervisor`, `virtiofsd`) and from the steps tested on a Mac. It ends in `kosa8 doctor` before anything else, so a runner that cannot host sandboxes fails with the reason rather than halfway through your tests. Check your runner exposes `/dev/kvm`. ``` name: sandbox-tests on: [push] jobs: test: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 22 } - name: Allow this user to use KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm.rules sudo udevadm control --reload-rules && sudo udevadm trigger --name-match=kvm - name: Install kosa8 and what it needs on Linux run: | sudo apt-get update && sudo apt-get install -y cloud-hypervisor virtiofsd curl -fsSL https://kosa8.com/install | sh kosa8 doctor kosa8 daemon start - run: npm ci - run: node test/sandbox.test.mjs # your tests, using @kosa8/sdk ``` On Linux, images must be built for `linux/amd64` on an x86-64 runner. The first sandbox downloads the guest image, about 1.5 GB unpacked; cache `~/.kosa8/guest` between runs to skip it. ## MCP from TypeScript frameworks `kosa8 mcp serve` speaks MCP over stdio. Each framework below starts it as a child process and turns its 30 tools into that framework's tools. Each snippet was run, except the Claude Agent SDK one. The line `kosa8 MCP gateway: AI session …` that `mcp serve` prints to stderr is left out of the outputs below. ### Vercel AI SDK MCP client ``` $ npm install @ai-sdk/mcp ``` ``` import { createMCPClient } from '@ai-sdk/mcp'; import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio'; // kosa8 mcp serve speaks MCP over stdio; the client starts it as a child process. const mcp = await createMCPClient({ transport: new Experimental_StdioMCPTransport({ command: 'kosa8', args: ['mcp', 'serve', '--allow-mount', `${process.env.HOME}/projects:rw`], env: { HOME: process.env.HOME, PATH: process.env.PATH }, }), }); try { const tools = await mcp.tools(); // AI SDK tools, ready for generateText({ tools }) console.log(Object.keys(tools).length, 'tools:', Object.keys(tools).slice(0, 6).join(', '), '…'); // Call a few directly, the way a model's tool calls would. const opts = { toolCallId: 't', messages: [] }; const show = (r) => r.content.map((c) => c.text).join('').trim(); console.log(show(await tools.kosa8_status.execute({}, opts))); console.log(show(await tools.sandbox_create.execute({ name: 'mcp-demo', egress: 'deny' }, opts))); console.log(show(await tools.sandbox_pull.execute({ sandbox: 'mcp-demo', image: 'alpine:latest' }, opts))); console.log(show(await tools.sandbox_run.execute({ sandbox: 'mcp-demo', image: 'alpine:latest', command: ['uname', '-a'] }, opts))); console.log(show(await tools.sandbox_delete.execute({ sandbox: 'mcp-demo' }, opts))); } finally { await mcp.close(); } ``` ``` $ node mcp-aisdk.mjs 30 tools: container_exec, container_list, container_logs, container_run, container_stats, container_stop … { "commit": "ffaa677", "go": "go1.26.9", "phase": "0-foundations", "status": "ok", "uptime_seconds": 2296, "version": "0.9.10" } { "boot_ms": 1168, "sandbox": { "cpus": 2, "created": "2026-10-15T20:34:22.598582-04:00", "disk_gb": 32, "egress": "deny", "id": "mcp-demo", "ip": "192.168.127.2", "mem_mb": 1024, "name": "mcp-demo", "state": "running" } } pulled docker.io/library/alpine:latest into mcp-demo Linux kosa8-guest 6.18.35 #1 SMP Sat Jul 18 15:25:41 UTC 2026 aarch64 Linux deleted mcp-demo ``` Tested with `@ai-sdk/mcp` 2.0.77. Pass `tools` to `generateText` and the model can create, use and delete sandboxes itself. ### LangChain.js MCP adapters ``` $ npm install @langchain/mcp-adapters @langchain/core ``` ``` import { MultiServerMCPClient } from '@langchain/mcp-adapters'; const client = new MultiServerMCPClient({ mcpServers: { kosa8: { transport: 'stdio', command: 'kosa8', args: ['mcp', 'serve'], env: { HOME: process.env.HOME, PATH: process.env.PATH }, }, }, }); try { const tools = await client.getTools(); // LangChain tools, named _ console.log(tools.length, 'tools, e.g.', tools.slice(0, 4).map((t) => t.name).join(', ')); const list = tools.find((t) => t.name === 'kosa8_sandbox_list'); console.log('sandbox_list ->', await list.invoke({})); } finally { await client.close(); } ``` ``` $ node mcp-langchain.mjs 30 tools, e.g. kosa8_container_exec, kosa8_container_list, kosa8_container_logs, kosa8_container_run sandbox_list -> [ { "cpus": 2, "created": "2026-10-15T20:01:13.262607-04:00", "disk_gb": 32, "egress": "deny", "id": "runner", "ip": "192.168.127.2", "mem_mb": 1024, "name": "runner", "state": "running" } ] ``` Tested with `@langchain/mcp-adapters` 2.0.1. Tool names are prefixed with the server name. ### Mastra ``` $ npm install @mastra/mcp ``` ``` import { MCPClient } from '@mastra/mcp'; const mcp = new MCPClient({ servers: { kosa8: { command: 'kosa8', args: ['mcp', 'serve'], env: { HOME: process.env.HOME, PATH: process.env.PATH } }, }, }); try { const tools = await mcp.listTools(); // pass to new Agent({ tools }) in Mastra const names = Object.keys(tools); console.log(names.length, 'tools, e.g.', names.slice(0, 4).join(', ')); const status = tools['kosa8_kosa8_status']; console.log('kosa8_status ->', JSON.stringify(await status.execute({})).slice(0, 160)); } finally { await mcp.disconnect(); } ``` ``` $ node mcp-mastra.mjs 30 tools, e.g. kosa8_container_exec, kosa8_container_list, kosa8_container_logs, kosa8_container_run kosa8_status -> {"content":[{"type":"text","text":"{\n \"commit\": \"ffaa677\",\n \"go\": \"go1.26.9\",\n \"phase\": \"0-foundations\",\n \"status\": \"ok\",\n \"uptime_se ``` Tested with `@mastra/mcp` 2.4.1. Keys are `_`, so kosa8's own `kosa8_status` becomes `kosa8_kosa8_status`. ### Claude Agent SDK ``` import { query } from '@anthropic-ai/claude-agent-sdk'; // Claude Agent SDK: give the agent kosa8's tools over MCP. (Typechecked against the SDK's types; not run.) for await (const message of query({ prompt: 'Create a sandbox with no network, run `uname -a` in alpine inside it, then delete it.', options: { mcpServers: { kosa8: { type: 'stdio', command: 'kosa8', args: ['mcp', 'serve'] }, }, allowedTools: ['mcp__kosa8__sandbox_create', 'mcp__kosa8__sandbox_pull', 'mcp__kosa8__sandbox_run', 'mcp__kosa8__sandbox_delete'], }, })) { if (message.type === 'result') console.log(message); } ``` typechecked not run This compiles against `@anthropic-ai/claude-agent-sdk` 0.3.301 with `strict` TypeScript. It was not run, because a run needs a Claude API key. MCP tools appear to Claude as `mcp____`. ## dockerode and Testcontainers via DOCKER_HOST Existing Docker code runs unchanged. Containers run in kosa8's shared engine VM. Bind mounts work below folders you allow when starting the daemon: ``` $ kosa8 daemon start --allow-mount ~/projects:rw docker bind mounts allowed under /private/tmp/…/home/projects:rw $ export DOCKER_HOST=unix://$HOME/.kosa8/run/kosa8.sock ``` ### dockerode ``` // DOCKER_HOST=unix://$HOME/.kosa8/run/kosa8.sock node dockerode.mjs import Docker from 'dockerode'; const docker = new Docker(); // reads DOCKER_HOST const v = await docker.version(); console.log('engine', v.Version, 'api', v.ApiVersion, '|', v.Components.map((c) => `${c.Name} ${c.Version}`).join(', ')); await new Promise((resolve, reject) => docker.pull('alpine:3.20', (err, stream) => err ? reject(err) : docker.modem.followProgress(stream, (e) => (e ? reject(e) : resolve())))); // A bind mount below a folder the daemon was started with: --allow-mount /tmp/k8d/projects:rw const c = await docker.createContainer({ Image: 'alpine:3.20', Cmd: ['sh', '-c', 'cat /site/index.html; echo "built" > /site/out.txt'], HostConfig: { Binds: [`${process.env.HOME}/projects/site:/site:rw`] }, }); await c.start(); console.log('exit', (await c.wait()).StatusCode); console.log((await c.logs({ stdout: true })).toString('utf8').replace(/[\x00-\x08]/g, '').trim()); await c.remove({ force: true }); // A bind outside the allowed folders is refused before anything runs. try { await docker.createContainer({ Image: 'alpine:3.20', HostConfig: { Binds: [`${process.env.HOME}/app:/app`] } }); } catch (err) { console.log('refused:', err.statusCode, err.json?.message ?? err.message); } ``` ``` $ node dockerode.mjs engine 28.3.0 api 1.51 | Engine 28.3.0, kosa8d 0.9.10 exit 0

fixture

refused: 400 bind mount of /tmp/k8d/app refused: it is outside the directories allowed for Docker bind mounts (/private/tmp/…/home/projects:rw); allow it with `kosa8 daemon stop && kosa8 daemon start --allow-mount DIR` (DIR:rw for write access; repeat for more directories) $ cat ~/projects/site/out.txt built ``` Tested with dockerode 5.0.1. kosa8 reports Docker Engine 28.3.0, API 1.51, and itself as the `kosa8d` component. (The `/private/tmp/…` in the output is the test's home folder after symlinks are resolved, shortened here.) ### testcontainers-node ``` // DOCKER_HOST=unix://$HOME/.kosa8/run/kosa8.sock TESTCONTAINERS_RYUK_DISABLED=true node tc.mjs import { GenericContainer, Wait } from 'testcontainers'; import { createClient } from 'redis'; const container = await new GenericContainer('redis:7-alpine') .withExposedPorts(6379) .withWaitStrategy(Wait.forLogMessage('Ready to accept connections')) .start(); try { const url = `redis://${container.getHost()}:${container.getMappedPort(6379)}`; const client = createClient({ url }); await client.connect(); await client.set('k', 'v1'); console.log('get:', await client.get('k'), 'via', url); await client.quit(); const ex = await container.exec(['redis-cli', 'get', 'k']); console.log('exec:', ex.exitCode, ex.output.trim()); } finally { await container.stop(); } ``` ``` $ DOCKER_HOST=unix://$HOME/.kosa8/run/kosa8.sock TESTCONTAINERS_RYUK_DISABLED=true node tc.mjs get: v1 via redis://localhost:59521 exec: 0 v1 ``` Tested with testcontainers 12.2.1 and redis 6.3.0. Keep `TESTCONTAINERS_RYUK_DISABLED=true`: Ryuk mounts the Docker socket, which kosa8 never shares into a container. Docker API compatibility in v0.9.10 also covers `docker cp`, `build`, `commit` and `exec -i/-w/-e/-u`; the full list, and Python and Java, are on [kosa8.com/docs/integrations](https://kosa8.com/docs/integrations/). These containers are isolated from your Mac by the engine VM, but share that VM with each other. For a container that should get a microVM of its own, use `@kosa8/sdk` instead. --- Source: https://docs.kosa8.com/guides/js-recipes/ # API reference Every export of `@kosa8/sdk` 0.1.0 and `@kosa8/ai-sdk-sandbox` 0.1.0. Signatures are taken from the packages' published `.d.ts` files, and the list of names on this page was checked against them. Field names in results are the daemon's JSON names, unchanged. [`Kosa8Client`Every method, with its endpoint.] [`Options`The option types methods take.] [`Result types`What methods resolve with.] [`Transport and helpers`Lower-level pieces.] [`Errors`The error classes and refusal types.] [`@kosa8/ai-sdk-sandbox`Functions, classes and settings.] ## @kosa8/sdk: `Kosa8Client` ``` new Kosa8Client(options?: Kosa8ClientOptions) readonly transport: Transport readonly checkpoints: CheckpointBook ``` Every method returns a promise, except `execFrames` (an async generator) and `sandbox`. Every method's last argument accepts `CallOptions` (`{ approval?, signal? }`), alone or merged into its own options. ### Status and the shared engine | Method | Resolves with | Endpoint | |---|---|---| | `health(opts?)` | `Health` | `GET /kosa8/v1/health` | | `version(opts?)` | `string` | `GET /kosa8/v1/health` | | `pull(ref, opts?)` | `{ ok, image }` | `POST /kosa8/v1/pull`, the shared engine | ### Sandboxes | Method | Resolves with | Endpoint | |---|---|---| | `createSandbox(options?: CreateSandboxOptions)` | `CreateSandboxResult` | `POST /kosa8/v1/sandboxes` | | `listSandboxes(opts?)` | `Sandbox[]` | `GET /kosa8/v1/sandboxes` | | `getSandbox(idOrName, opts?)` | `Sandbox \| undefined` | the list, filtered | | `startSandbox(id, opts?)` | `StartSandboxResult` | `POST …/sandboxes/{id}/up` | | `stopSandbox(id, opts?)` | `StopSandboxResult` | `POST …/sandboxes/{id}/down` | | `deleteSandbox(id, opts?: CallOptions & { wipe?, keepCheckpoints? })` | `DeleteSandboxResult & { checkpoints_dropped }` | `DELETE …/sandboxes/{id}` (`?wipe=1`), then its checkpoints' snapshots | | `pullIntoSandbox(id, ref, opts?)` | `{ ok, image }` | `POST …/sandboxes/{id}/pull` | | `sandboxImages(id, opts?)` | `string[]` | `GET …/sandboxes/{id}/images` | | `sandboxContainers(id, opts?)` | `Container[]` | `GET …/sandboxes/{id}/containers` | | `sandboxEgress(id, opts?)` | `EgressStatus` | `GET …/sandboxes/{id}/egress` | ### Running code | Method | Resolves with | Endpoint | |---|---|---| | `run(id, image, args?, opts?: RunOptions)` | `ExecResult` | `POST …/sandboxes/{id}/run`, NDJSON stream | | `startContainer(id, image, args?, opts?)` | container id | `POST …/sandboxes/{id}/start` | | `exec(id, container, args, opts?: ExecOptions)` | `ExecResult` | `POST …/sandboxes/{id}/exec` | | `execFrames(id, container, args, opts?)` | `AsyncGenerator` | `POST …/sandboxes/{id}/exec` | | `startInteractive(id, image, args?, opts?: CallOptions & { env?, tty? })` | container id | `POST …/sandboxes/{id}/start-interactive` | | `writeStdin(id, container, data: Uint8Array \| string \| AsyncIterable, opts?)` | `{ ok }` | `POST …/sandboxes/{id}/containers/{cid}/stdin` | | `attach(id, container, opts?: ExecOptions)` | `ExecResult` | `POST …/sandboxes/{id}/containers/{cid}/attach` | ### Snapshots, fork, explore | Method | Resolves with | Endpoint | |---|---|---| | `snapshot(id, opts?: CallOptions & { id?, encrypt? })` | `SnapshotSandboxResult` | `POST …/sandboxes/{id}/snapshot` | | `listSnapshots(opts?)` | `Snapshot[]` | `GET /kosa8/v1/snapshots` | | `restoreSnapshot(snapshotId, opts?: RestoreOptions)` | `RestoreResult` | `POST /kosa8/v1/snapshots/{id}/restore` | | `deleteSnapshot(snapshotId, opts?: CallOptions & { wipe?, force? })` | `DeleteSnapshotResult` | `DELETE /kosa8/v1/snapshots/{id}` | | `fork(id, opts?: ForkOptions)` | `ForkResult` | `POST …/sandboxes/{id}/fork` | | `explore(id, opts: ExploreOptions)` | `ExploreResult` | pull, fork, one run per branch, deletes | ### Checkpoints | Method | Resolves with | How | |---|---|---| | `checkpoint(sandbox, opts?: CheckpointOptions)` | `CheckpointResult` | a snapshot `-ckpt-`, recorded in the store; evicts past 10 | | `listCheckpoints(sandbox)` | `Checkpoint[]` | the store, oldest first | | `rewind(sandbox, opts?: RewindOptions)` | `RewindResult` | delete the sandbox, restore the checkpoint under its name, drop later ones | ### Handle `sandbox(id): SandboxHandle`. A `SandboxHandle` has `client`, `id`, and these methods with the sandbox filled in: `info` (`getSandbox`), `start`, `stop`, `delete`, `pull` (`pullIntoSandbox`), `run`, `startContainer`, `exec`, `containers`, `images`, `egress`, `snapshot`, `fork`, `explore`, `checkpoint`, `checkpoints` (`listCheckpoints`), `rewind`. There is no `execFrames`, `startInteractive`, `writeStdin` or `attach` on the handle; call them on the client. ## Options ### `Kosa8ClientOptions` Extends `TransportOptions` with one field. | Field | Type | Default | |---|---|---| | `socketPath` | string | `$HOME/.kosa8/run/kosa8.sock`; ignored when a host is set | | `host` | string | `KOSA8_HOST`: `https://…`, `http://…`, `tcp://h:p`, `h:p`, `unix:///path` | | `token` | string | `KOSA8_TOKEN`, when the host came from `KOSA8_HOST` | | `insecure` | boolean | `KOSA8_INSECURE=1`; allows plain http to a non-loopback host | | `actor` | `Actor`: `'human' \| 'agent' \| 'tool'` | `'tool'` | | `agentToken` | string | `KOSA8_AGENT_TOKEN` | | `headers` | `Record` | extra headers on every request | | `env` | `Record` | `process.env` | | `checkpointStore` | `CheckpointStore` | `new FileCheckpointStore()`, `~/.kosa8/checkpoints.json` | ### `CallOptions` `{ approval?: string; signal?: AbortSignal }`. `approval` is sent as `X-Kosa8-Approval`; `signal` aborts the HTTP request (not the process in the sandbox). ### `CreateSandboxOptions` (extends `CallOptions`) | Field | Type | Sent as | |---|---|---| | `name` | string | `name` | | `cpus` | number | `cpus` | | `memMb` | number | `mem_mb` | | `diskGb` | number | `disk_gb` | | `mounts` | `Mount[]` | `mounts` | | `egress` | `EgressPolicy` | `egress` (a `{ allow }` becomes `allow:a,b`) | | `egressAllow` | `readonly string[]` | `egress: "allow:…"` | | `capture` | `CaptureSpec` | `capture` | | `labels` | `Record` | `labels` | | `broker` | `BrokerSpec` | `broker` | ### Other option types | Type | Fields | |---|---| | `RunOptions` | `CallOptions` + `env?: Record \| string[]`, `onStdout?`, `onStderr?`, `onStatus?` (each `(chunk: Buffer) => void`) | | `ExecOptions` | `CallOptions` + `onStdout?`, `onStderr?` | | `ForkOptions` | `CallOptions` + `n?` (default 1), `prefix?` (default `-fork`), `label?` | | `ExploreOptions` | `CallOptions` + `variants: string[]` (2 or more), `image?` (`alpine:latest`), `keepAll?`, `keep?`, `timeoutMs?` (120000), `prefix?`, `pull?` (true) | | `CheckpointOptions` | `CallOptions` + `label?` | | `RewindOptions` | `CallOptions` + `to?`: a checkpoint id or label (default: the latest) | | `RestoreOptions` | `CallOptions` + `name?`, `label?`, `allowTampered?` | | `Mount` | `{ host_path: string; guest_path: string; read_only: boolean }` | | `EgressPolicyString` | `'any' \| 'deny' \| 'fakenet' \| `allow:${string} | | `EgressPolicy` | `EgressPolicyString \| { allow: readonly string[] }` | | `CaptureSpec` | `{ path: string; max_bytes?: number; max_files?: number }` | | `BrokerSpec` | `{ allow?: BrokerHostRule[]; routes?: BrokerRoute[] }` | | `BrokerHostRule` | `{ host: string; ports?: number[]; methods?: string[] }` | | `BrokerRoute` | `{ name; upstream; header; secret; methods?; paths?; scheme? }`, all strings or string arrays | ## Result types | Type | Fields | |---|---| | `Health` | `status, version, commit, go, uptime_seconds, phase` | | `Sandbox` | `id, name, created, state` (running, paused, stopped), `cpus, mem_mb, disk_gb?, parent?, mounts?, egress?, capture?, ip?, labels?, broker?: BrokerInfo` | | `BrokerInfo` | `url, hosts?, routes?` | | `CreateSandboxResult` | `sandbox, boot_ms, broker?: { url, placeholders }` | | `StartSandboxResult` | `sandbox, boot_ms, warning?` | | `StopSandboxResult` | `ok, sandbox?, warning?, already_stopped?` | | `DeleteSandboxResult` | `ok, wipe?, files?` | | `Container` | `id, image, status, exit?, ip?` | | `EgressStatus` | `policy, stats: { passed, denied, dropped, dns_answered, dns_refused, fakenet? }, capture?, wildcards?, fakenet?` | | `Frame` | `stream?` (stdout, stderr, status), `data?: Buffer`, `done?, ok?, error?, exit?, id?, image?` | | `ExecResult` | `exitCode, stdout, stderr, stdoutBytes: Buffer, stderrBytes: Buffer` | | `Snapshot` | `id, sandbox, created, cpus, mem_mb, cmdline, size_bytes, state_path`, and optional `machine_id, mac, disk_path, data_path, disk_gb, ephemeral, mounts, egress, received, received_mounts, sealed, labels, encryption` | | `SnapshotSandboxResult` | `snapshot, snapshot_ms, encrypted_by_policy?, encrypt_ms?, encryption?` | | `RestoreResult` | `sandbox, restore_ms, seal?` | | `DeleteSnapshotResult` | `ok, removed, encrypted, shred?, wipe?, files?` | | `ForkResult` | `forks: Sandbox[], snapshot, fork_ms, count, encrypted_by_policy?, encrypt_ms?` | | `ExploreBranch` | `name, variant, exit, output, duration_ms, error?` | | `ExploreResult` | `parent, branches, winner` (`""` if none succeeded), `removed` | | `Checkpoint` | `id, sandbox, label, created, auto?` | | `CheckpointResult` | `checkpoint, evicted: string[], snapshot: SnapshotSandboxResult` | | `RewindResult` | `sandbox, checkpoint, dropped: string[], restore_ms` | ## Transport, checkpoint stores and helpers | Export | What it is | |---|---| | `Transport` | `new Transport(opts?: TransportOptions)`. `target` (where requests go), `raw(method, path, opts?: RequestOptions)` (headers arrived), `json(method, path, opts?)` (decoded; HTTP errors become typed errors), `frames(method, path, opts?)` (NDJSON frames, `data` decoded). For endpoints the client does not wrap. | | `TransportOptions` | `socketPath?, host?, token?, insecure?, actor?, agentToken?, headers?, env?` | | `RequestOptions` | `query?, body?` (JSON), `rawBody?` (bytes, string or async iterable, streamed), `approval?, signal?, headers?` | | `Actor` | `'human' \| 'agent' \| 'tool'` | | `resolveEndpoint(opts: TransportOptions)` | `{ kind: 'unix', socketPath } \| { kind: 'tcp', url, token? }`, following the CLI's `KOSA8_HOST` rules. Throws `Kosa8Error` for a refused address. | | `defaultSocketPath()` | `$HOME/.kosa8/run/kosa8.sock` | | `APPROVAL_HEADER`, `ACTOR_HEADER`, `AGENT_TOKEN_HEADER` | `'X-Kosa8-Approval'`, `'X-Kosa8-Actor'`, `'X-Kosa8-Agent-Token'` | | `CheckpointStore` | Interface: `load(): Promise>`, `update(fn): Promise` (serialised). | | `FileCheckpointStore` | `new FileCheckpointStore(filePath?)`, default `~/.kosa8/checkpoints.json` (mode 0600, atomic rewrite, lock file for Node writers). `path`. | | `MemoryCheckpointStore` | In memory only; nothing shared with the CLI. | | `CheckpointBook` | The stack logic over a store, as `client.checkpoints`: `store`, `nextId`, `record`, `list`, `latest`, `resolve`, `pop`, `forget`. | | `MAX_CHECKPOINTS_PER_SANDBOX` | `10` | | `validateSandboxName(name)` | Throws `Kosa8CheckpointError` for a name that could escape the store (empty, `.`, `..`, a slash, a leading dot). | ## Errors | Export | Extends | Thrown for | |---|---|---| | `Kosa8Error` | `Error` | Base class; client-side checks. Fields `status?, daemonMessage?, remediation?, method?, path?, body?`. | | `Kosa8ConnectionError` | `Kosa8Error` | Daemon unreachable. | | `Kosa8ApiError` | `Kosa8Error` | Any HTTP error without a more specific class. | | `Kosa8BadRequestError` | `Kosa8ApiError` | 400 | | `Kosa8AuthError` | `Kosa8ApiError` | 401 | | `Kosa8PolicyError` | `Kosa8ApiError` | 403 | | `Kosa8ApprovalRequiredError` | `Kosa8PolicyError` | 403 with a refusal; `approval: ApprovalRefusal` | | `Kosa8NotFoundError` | `Kosa8ApiError` | 404 | | `Kosa8ConflictError` | `Kosa8ApiError` | 409 | | `Kosa8UnsupportedError` | `Kosa8ApiError` | 501 | | `Kosa8StreamError` | `Kosa8Error` | Error frame in a stream; `stdout`, `stderr` | | `Kosa8CheckpointError` | `Kosa8Error` | Checkpoint bookkeeping | | `errorFromResponse(status, body, ctx?)` | | Builds the typed error for an HTTP error answer, as the client does. | | `ApprovalRefusal` | type | `route, action: ApprovalAction, description, class, policy, policy_digest, requirement: ApprovalRequirement, request_id?, why, signers?, request_command` | | `ApprovalAction` | type | `kind, target?, command?, sandbox?`, and more | | `ApprovalRequirement` | type | `policy, rules: ApprovalRule[]` | | `ApprovalRule` | type | An approval rule as the daemon reports it (an open record). | Details and real messages: [Errors reference](https://docs.kosa8.com/guides/js-errors/). ## @kosa8/ai-sdk-sandbox ### Functions | Export | Signature and meaning | |---|---| | `createKosa8NetworkSandboxSession` | `(options?: Kosa8NetworkSandboxSessionCreateOptions) => Promise`. Boots a sandbox and its work container. Options are the settings plus `sandboxId?`, `template?: { identity, prepare }`, `abortSignal?`. | | `resumeKosa8NetworkSandboxSession` | `(options: Kosa8NetworkSandboxSessionResumeOptions) => Promise<…>`. Settings plus `sandboxId` (required), `abortSignal?`. Reattaches; never creates. | | `createKosa8NetworkSandboxSessionFromSandbox` | `(options: Kosa8SandboxSettings & { sandbox; container?; abortSignal? }) => Promise<…>`. Wraps a sandbox you own; `stop` and `destroy` do nothing. | | `createKosa8SandboxSessionFromContainer` | `(options: { sandbox; container; client?; image?; workingDirectory?; env?; approval? }) => Experimental_SandboxSession`. The restricted view only. | | `createKosa8Sandbox` | `(settings?: Kosa8SandboxSettings) => HarnessV1SandboxProvider`. The provider shape. | | `toKosa8Egress` | `(policy: HarnessV1NetworkPolicy) => string`. The AI SDK policy as kosa8 egress; throws `HarnessCapabilityUnsupportedError` for `deniedCIDRs` or an empty custom list. | | `restrictedView` | `(session: Experimental_SandboxSession) => Experimental_SandboxSession`. A frozen object of the eight tool-safe members. | | `sandboxNameForSession` | `(sessionId: string) => string`. `aisdk--`. | | `templateSnapshotId` | `(identity, image, egress, workdir) => string`. `aisdk-tpl-`. | | `resumeSnapshotId` | `(sandbox: string) => string`. `-aisdk-resume`. | ### Constants `DEFAULT_IMAGE` = `'alpine:latest'`, `DEFAULT_WORKING_DIRECTORY` = `'/workspace'`, `KOSA8_PROVIDER_ID` = `'kosa8-sandbox'`. ### Classes | Class | Members | |---|---| | `Kosa8SandboxSession` (implements `Experimental_SandboxSession`) | `description`, `run({ command, workingDirectory?, env?, abortSignal? })`, `spawn(…)` returning `{ stdout, stderr, wait(), kill() }`, `readFile`, `readBinaryFile`, `readTextFile`, `writeFile`, `writeBinaryFile`, `writeTextFile`. | | `Kosa8NetworkSandboxSession` (extends the above, implements `HarnessV1NetworkSandboxSession`) | `id`, `sandboxName`, `containerId`, `defaultWorkingDirectory`, `ports` (`[]`), `restricted()`, `stop()`, `destroy()`, `setNetworkPolicy(policy)`, `getPortEndpoint()` and `getPortUrl()` (throw), `setPorts(ports)` (throws unless empty). | | `Kosa8SandboxProvider` (implements `HarnessV1SandboxProvider`) | `new Kosa8SandboxProvider(settings?)`. `specificationVersion` (`'harness-sandbox-v1'`), `providerId`, `createSession({ sessionId?, abortSignal?, identity?, onFirstCreate? })`, `resumeSession({ sessionId, abortSignal? })`. | ### Types | Type | Fields | |---|---| | `Kosa8SandboxSettings` | `client?, image?, workingDirectory?, env?, networkPolicy?, egress?, cpus?, memMb?, diskGb?, mounts?, labels?, broker?, setupCommands?, approval?` | | `Kosa8NetworkSandboxSessionCreateOptions` | `HarnessV1SandboxSessionCreateOptions` | | `Kosa8NetworkSandboxSessionResumeOptions` | `HarnessV1SandboxSessionResumeOptions` | Usage and behaviour: [Vercel AI SDK adapter](https://docs.kosa8.com/guides/js-ai-sdk/). --- Source: https://docs.kosa8.com/guides/js-api/ # Troubleshooting The problems below were all met while writing and testing these pages, and the messages are quoted as kosa8 v0.9.10 and the 0.1.0 packages printed them. Search the page for a few words of yours. ## The daemon is not running ``` Kosa8ConnectionError: kosa8 daemon unreachable at unix:///tmp/k8d/.kosa8/run/kosa8.sock: connect ENOENT /tmp/k8d/.kosa8/run/kosa8.sock (is kosa8 installed and its daemon running? start it with `kosa8 daemon start`) ``` **What to do.** `kosa8 daemon start`, then `kosa8 daemon status`. If it will not start, read `~/.kosa8/log/kosa8d.log` and run `kosa8 doctor`. After a daemon restart, every sandbox is stopped: `startSandbox` boots it from its disk. **Also this message:** if a v0.9.10 daemon was started with `--listen`, it serves TCP instead of the socket, and the SDK needs `KOSA8_HOST` and `KOSA8_TOKEN`; see [Connecting](https://docs.kosa8.com/guides/js-connecting/#tcp). Fixed in kosa8 0.9.36: `--listen` keeps the unix socket, so the default client works there too. ## The socket path is too long (macOS) ``` kosa8: kosa8d did not become ready; check /private/tmp/claude-501/…/longhome/.kosa8/log/kosa8d.log kosa8d: server stopped: listen unix /private/tmp/claude-501/…/longhome/.kosa8/run/kosa8.sock: bind: invalid argument Kosa8ConnectionError: kosa8 daemon unreachable at unix:///private/tmp/claude-501/…/longhome/.kosa8/run/kosa8.sock: connect EINVAL … ``` **Why.** macOS limits a unix socket path to 104 bytes, and the socket lives at `$HOME/.kosa8/run/kosa8.sock`. That one was 131 bytes. It happens in CI, containers and test harnesses that set `HOME` to a deep temporary folder. **What to do.** Use a shorter `HOME`, or a short symlink to it (`ln -s /very/long/dir /tmp/k8 && HOME=/tmp/k8`), for both the daemon and your program. The tests for these pages ran with `HOME=/tmp/k8d`. ## "permission denied" when restoring, on a Mac ``` restore machine state: Error Domain=VZErrorDomain Code=12 Description="An error occurred while restoring the virtual machine. The virtual machine failed to restore with error “permission denied”." UserInfo={ NSLocalizedFailure = "An error occurred while restoring the virtual machine."; NSLocalizedFailureReason = "The virtual machine failed to restore with error \U201cpermission denied\U201d."; } (list snapshots with `kosa8 snapshot ls`) ``` **Why.** Apple's Virtualization framework protects a saved machine's state with a key held in the Secure Enclave, and that key can only be used while the Mac is unlocked. With the screen locked, every restore fails: `rewind`, `fork`, `explore`, `restoreSnapshot`, and the AI SDK adapter's resume and templates. Saving still works, so checkpoints and snapshots are taken without complaint and fail only when you go back to them. The system log shows the cause: ``` $ log show --last 5m --predicate 'process CONTAINS[c] "virtualization" AND messageType == error' … [com.apple.security:seckey] SecKeyCreateDecryptedDataWithParameters failed: Error Domain=NSOSStatusErrorDomain Code=-25308 ": unable to compute shared secret" UserInfo={NSDebugDescription=: unable to compute shared secret, AKSError=-536870174} ``` Code `-25308` is `errSecInteractionNotAllowed`. In our tests every restore failed for as long as the screen was locked, and the same calls worked as soon as it was unlocked. The key is the framework's own, not kosa8's: kosa8 keeps no keychain or Secure Enclave key on the restore path, so there is no setting on its side that lifts the restriction. Saving needs only the public half of the key, which is why it keeps working. **From kosa8 0.9.36** the error says so plainly, keeps Apple's text after it, and comes back as HTTP 503 (a plain `Kosa8ApiError` with `status` 503) rather than 500: ``` macOS blocks restoring a VM while the screen is locked; unlock and retry (restore machine state: Error Domain=VZErrorDomain Code=12 …) (unlock the Mac, then retry; snapshots can still be taken while it is locked, but nothing can be restored, forked or rewound) ``` **What to do.** Keep the Mac unlocked while jobs that restore are running, or run them on Linux. If a rewind failed this way, the sandbox has been removed and its checkpoint is intact: unlock, then run the `restoreSnapshot(…)` call the error message gives. On v0.9.10 a failed fork leaves debris; see below. From 0.9.36 a failed fork leaves nothing. ## Disk space Sandboxes are cheap on disk until they are not. Things that use real space: - **The guest image**, about 1.5 GB in `~/.kosa8/guest`, once. - **Memory saves.** Every checkpoint, snapshot and fork writes the guest's memory state: about 80 MiB for an idle 1 GiB sandbox in our tests, more for a busy one. - **Writes to cloned disks.** Disks are copy-on-write clones, so a copy costs nothing until either side writes, then it costs what was written. - **Memory of running copies.** On a Mac, many running sandboxes push memory into swap, and swap lives on the same disk. - **Wipes and encryption.** `wipe: true` and `encrypt: true` overwrite data, and on APFS an overwrite goes to new blocks, so they need free space while they run. What running low looked like: ``` snapshot runner-encrypted was taken but not encrypted: encrypted, but the plaintext of disk.img could not be wiped; run `kosa8 snapshot encrypt runner-encrypted` again fork 3/3: sandbox tz3 already exists (sandbox must be running) ``` The second came on the retry after a four-way explore failed partway on a nearly full disk. The failed fork left two copies and its snapshot behind, and its own files, which no listing showed. Fixed in kosa8 0.9.36: a fork keeps all of its copies or none. If one copy fails, the copies already made are removed with their disks, the fork's snapshot is deleted and every name is free again; the error says so: ``` fork 3/4: no space left on device (nothing was kept: the 3 copies already made were removed, with the fork's snapshot, and the names are free again) ``` **What to do.** Check free space before big operations (`df -h ~`). Keep sandboxes small (`memMb`) when you fork them. Delete what you are done with: `listSandboxes()` and `listSnapshots()` show leftovers, including fork snapshots named `-fork-…` and adapter templates named `aisdk-tpl-…`. On v0.9.10, after a failed fork, retry with a new `prefix`. Files left in `~/.kosa8/sandboxes/` with no matching sandbox in `kosa8 sandbox ls` are debris from a failed operation. ## Node version `@kosa8/sdk` needs Node 18 or later; `@kosa8/ai-sdk-sandbox` needs 22 or later, because `@ai-sdk/harness` does. Both declare it in `engines`, so npm warns at install time on an older Node. Check with `node --version`. These pages were tested on Node 25.9.0 only; older versions were not run. ## A request from inside the sandbox fails ``` wget: bad address 'github.com' urllib.error.URLError: ConnectionRefusedError: [Errno 111] Connection refused ``` **Why.** The sandbox's egress rule does not allow that host. A name not on the list does not resolve, and a connection to an address not on it is refused. Nothing is thrown in Node: the command fails with its own error and a non-zero exit code. **What to do.** `sandboxEgress(id)` shows the rule and its counters; `kosa8 audit ls` shows each `sandbox.egress.deny`. The rule cannot be changed on a running sandbox: create a new one with the host in `egressAllow`. For a model API, use a [broker route](https://docs.kosa8.com/guides/js-network/#broker) rather than allowing the host and putting the key inside. Through the broker's proxy, busybox `wget` got `HTTP/1.1 400 Bad Request` for HTTPS sites on v0.9.10; use `curl`, which works. Fixed in kosa8 0.9.36: busybox has no `CONNECT` and sends `GET https://host/path` to the proxy, which the broker now forwards, making the TLS connection and checking the certificate itself. ## A call needs approval ``` Kosa8ApprovalRequiredError: policy "sdk-docs-test" requires 1 signed approval(s) for destructive, from people other than the requester: `sandbox-rm runner` needs signed approval before it can run — no approval was presented with the call (…) ``` **What to do.** Follow [Approvals and policy](https://docs.kosa8.com/guides/js-approvals/): a person requests, an approver signs, you repeat the call with `{ approval: id }`. Three things that went wrong for us: - `--reason` written after the action is recorded as part of the action on v0.9.10. Put flags first: `kosa8 approve request --reason "…" sandbox-rm runner`. Fixed in kosa8 0.9.36: the refusal's hint prints flags first, and a `--reason` at the very end is read as the reason. - "needs 2 distinct approver(s)" with a policy that says 1: the requester is not listed in the policy. List them. - "was already used": an approval covers one call. Methods like `rewind` make several calls; see [composite calls](https://docs.kosa8.com/guides/js-approvals/#composite). ## An AI SDK script does not exit The program prints everything and keeps running. With `process._getActiveHandles()` you would see one `Socket` per affected command. This is a known issue in `@kosa8/ai-sdk-sandbox` 0.1.0, fixed in 0.1.1; with 0.1.0, end the script with `process.exit()`. Details on [the adapter page](https://docs.kosa8.com/guides/js-ai-sdk/#known-issues). ## Other things that surprised us | Symptom | Cause and fix | |---|---| | The first `createSandbox` takes 30 seconds or more | The daemon is downloading and checking the guest image (35 s in our test). Later creates take about a second. | | `Kosa8StreamError: image "…" not found in engine` | `run` does not pull. Call `pullIntoSandbox` first. | | A file written in one `run` is missing in the next | Each `run` is a new container. Use `startContainer` and `exec`, or a mount. The same holds for `explore` variants. | | A `timeout` inside the container never fires | The command became PID 1, which ignores signals. Keep a shell as PID 1: `sh -c 'timeout -s KILL 10 cmd; exit $?'`. See [the recipe](https://docs.kosa8.com/guides/js-recipes/#llm-code). | | A command keeps running after an aborted `run` | Aborting ends the request, not the process. Limit time inside the sandbox. | | A second `writeStdin` has no effect | Stdin closes when the first body ends. Pass an async iterable to stream input over time. | | Creating a sandbox with a taken name gives HTTP 500 | v0.9.10 reports it as `Kosa8ApiError`, "sandbox NAME already exists", not as a 409. Fixed in kosa8 0.9.36: a `Kosa8ConflictError` (409), for create, restore and fork. | | "Exec format error" | An image built only for the other CPU architecture. Nothing is emulated: use an arm64 image on Apple silicon, amd64 on x86-64 Linux. | | A background process started inside the adapter's `run` is gone | It does not outlive the command that started it. Run long-lived processes as a container's main process. | For refusals that come from an agent's tool calls and MCP, see [Agent troubleshooting](https://docs.kosa8.com/guides/troubleshooting/). --- Source: https://docs.kosa8.com/guides/js-troubleshooting/ # Company rules Decide centrally which registries, websites and AI tools are allowed. The rules are signed by your organisation, so editing them on a developer's laptop locks that laptop out rather than opening it up. ## The problem AI tools gain abilities by plugging into other software — one plugin reads your tickets, another writes to your repositories, another can delete things. Each arrives with all of its abilities switched on. kosa8 lets an administrator approve them one at a time, per team. ### Make a signing key ``` $ kosa8 policy keygen public: kQ+JPoaWWs9JC1Nic/sRli1nJOAgQFAmmm+xtD0oEeo= private: F+XQvHHIRNl7921Jt8r1G5TnOYdhcqkBNubo/aPkm... ``` Keep the private half where your organisation keeps signing keys. Anyone who has it can write policy for every machine. ### Write the policy ``` $ kosa8 mcp-policy init > policy.json ``` ``` { "name": "example-corp", "default_scope": "engineering", "scopes": [ { "name": "engineering", "servers": [ { "server": "github", "allow_tools": ["list_*", "get_*", "search_*"], "deny_tools": ["*_delete", "delete_*"] } ] } ] } ``` Wildcards work for tool names but never for the server itself — approving every server by pattern would defeat the control. ### Sign it, pin the signer, install it ``` $ kosa8 mcp-policy sign key.txt policy.json > signed.json $ kosa8 policy trust kQ+JPoaWWs9JC1Nic/sRli1nJOAgQFAmmm+xtD0oEeo= $ kosa8 mcp-policy install signed.json installed MCP policy "example-corp" (2 scope(s)) servers not listed in a scope are now denied ``` Pinning first is not optional — installing before you pin would accept any key, including one a developer generated themselves. kosa8 refuses: ``` no trusted policy signer is pinned, so any key would be accepted here — pin your org's public key first with `kosa8 policy trust ` ``` ## What it does ``` $ kosa8 mcp-policy check github list_issues allowed: github/list_issues for scope engineering tool "list_issues" on server "github" is allowed for scope "engineering" by rule allow_tools "list_*" rule: engineering/github allow_tools "list_*" $ kosa8 mcp-policy check github delete_repo DENIED: github/delete_repo for scope engineering tool "delete_repo" on server "github" is denied for scope "engineering" by rule deny_tools "delete_*" rule: engineering/github deny_tools "delete_*" $ kosa8 mcp-policy check randomvendor read DENIED: randomvendor/read for scope engineering MCP server "randomvendor" is not approved for scope "engineering"; approved servers: docs, github — ask your admin to add it to the org policy ``` A server nobody listed is denied, and once a server has an `allow_tools` list, a tool that matches none of its patterns is denied. Approval has to be written down, not merely un-forbidden. ## Tampering locks the door If someone edits the installed policy to grant themselves a permission, the signature stops matching and kosa8 refuses *everything* — including what they were legitimately allowed to do a moment earlier. ``` $ kosa8 mcp-policy check github list_issues DENIED: github/list_issues for scope — the installed MCP policy failed verification: signature does not match MCP policy (tampered or wrong key); re-install a policy signed by your org with `kosa8 mcp-policy install` ``` ## The record ``` $ kosa8 mcp-policy events # every allow and deny $ kosa8 audit ls # every state-changing action $ kosa8 audit verify audit chain intact: 982 entries verified $ kosa8 audit export --format cef # for your SIEM ``` Each entry is sealed with the one before it. Change an old entry and every later one stops matching, and kosa8 tells you which one broke. **What this does not do yet** There is no hosted admin console and no SAML or SCIM integration — policies are a signed file you distribute yourself. With no policy installed, every registered tool stays reachable, so upgrading does not silently break anyone's setup. Governance begins when you install one. --- Source: https://docs.kosa8.com/guides/governance/ # Policy reference kosa8 reads two documents an organisation signs. The organisation policy sets rules for the machine: which registries, which network, which models, who may approve. The MCP policy sets rules for AI tools: which tool servers, which tools, with which arguments. This page lists every field of both, where each one is enforced, and what you see when it refuses. If you have not installed a policy before, start with [Company rules](https://docs.kosa8.com/guides/governance/), which walks through it. This page is the reference you return to. | | Organisation policy | MCP policy | |---|---|---| | Commands | `kosa8 policy` | `kosa8 mcp-policy` | | Installed at | `~/.kosa8/policy.json` | `~/.kosa8/mcp-policy.json` | | Signed with | The same organisation key. Its public half is pinned at `~/.kosa8/policy-trust.pub`. | | | Decides | Images, network defaults, approvals, audit, models, snapshots, agent identity | Tool servers, tools, arguments, tool definitions | ## Part A. The organisation policy A JSON object. Every field except `name` is optional, and a field left out restricts nothing. "Enforced" names the place in kosa8 that can refuse. | Field | Type | What it does | Where it is enforced | What you see | |---|---|---|---|---| | `name` | string | The policy's name. It appears in every refusal and in the audit log. | Nowhere on its own. | — | | `allowed_registries` | list of strings | The only registry hosts images may come from. An image with no registry in its name counts as `docker.io`. Empty means any. | Wherever the service fetches an image: `kosa8 pull`, a sandbox pull, the pull a run does for a missing image, and the Docker-compatible API. | `policy "example-corp" forbids registry "quay.io" (allowed: docker.io, ghcr.io)` | | `default_egress` | string | The network rule for a sandbox created without one: `deny` or `allow:a.com,b.com`. Empty means open. | When a sandbox is created. It is a default, not a ceiling: a request that names its own rule keeps it. | Nothing is refused. The sandbox's audit entry carries `egress=deny (org default)`. | | `require_sandbox` | true or false | Containers must run in a sandbox of their own, not on the shared engine. | `kosa8 run`, interactive runs, and container create on the Docker-compatible API. | `policy "example-corp" requires containers to run in a sandbox, and this would run on the shared engine; use `kosa8 sandbox create` and `kosa8 sandbox run | | `allowed_mcp` | list of strings | The only images a third-party tool server may run from, matched as whole names. Empty means any. | `kosa8 mcp add`, and again when the gateway starts the server. | `policy "example-corp" forbids MCP image "IMAGE" (allowed: …)` | | `require_fips` | true or false | kosa8 must be a build that uses a FIPS 140-3 validated cryptographic module. | Once, when the service starts. It refuses to start otherwise. | `kosa8d: policy "example-corp" requires a FIPS 140-3 validated cryptographic module, and this binary was not built with one (rebuild with GOFIPS140=certified, or install a FIPS release)` | | `approvers` | list of [approvers] | The people whose signed approvals count. Listing anyone turns signed approvals on. | In front of every route of the service, so the CLI, AI tools and the Docker-compatible API are covered alike. | A refusal that names the action, says it `needs signed approval before it can run`, lists who can sign, and gives the commands to request it. | | `approval_rules` | list of [rules] | Which actions need signed approval, and from how many people. With approvers and no rules, the rule is one approver for escalating actions. | Same place. Every rule that matches an action must be met. | As above. | | `allow_unlisted_requesters` | true or false | Lets someone who is not an approver ask for an approval. Off by default, so a listed approver cannot request under a throwaway key and approve under their own. | When an approval request is recorded. | The request is refused. | | `require_audit_forwarding` | true or false | Audit entries must leave the machine: at least one sink must be configured in `~/.kosa8/audit-config.json`. | Once, when the service starts. | `kosa8d: policy "example-corp" requires audit records to be forwarded off this machine, and no sink is configured: add one to ~/.kosa8/audit-config.json …` | | `min_audit_retention_days` | whole number | The shortest time entries stay in the live log. Local settings can keep them longer, never shorter. | When retention runs, hourly in the service and in `kosa8 audit prune`. | Younger entries are kept. The prune report says `org policy "example-corp" keeps entries for at least 400 days`. | | `labels` | object | The classification levels a sandbox or snapshot may carry and what each permits: destinations, shared folders, widest network. Left out, the levels are `UNCLASSIFIED` and `CUI`. | When a sandbox is created, started, relabelled, or a snapshot leaves the machine. | The request is refused with the label rule it broke. `kosa8 label ls` shows what is allowed. | | `allowed_models` | list of [models] | The only model files the local model runtime may load, each named by the SHA-256 of its file. Empty means any. | `kosa8 model serve`. The built-in assistant also refuses a hosted provider, whose model cannot be pinned. | `policy "example-corp" does not approve model NAME: its weights hash to sha256:…, which is not on the approved model list …` | | `model_recording` | string | What the model gateway records about each request: `off` (the default), `digests`, or `content` (digests plus the text, encrypted). | The model gateway, on every request it serves. | Nothing is refused. Entries appear in the audit log. | | `require_snapshot_encryption` | true or false | Every snapshot is encrypted as soon as it is taken, and may leave or enter the machine only encrypted. | The service at start (it needs a usable key), on every snapshot, and on push and pull. | `policy "example-corp" requires snapshots to be encrypted, and this transfer would send it in plaintext; push it with --encrypt …` | | `snapshot_key_scope` | string | The key scope incremental transfers must use: `snapshot` (one key per snapshot) or `lineage` (one per sandbox, which also permits `snapshot`). Empty means `snapshot` when encryption is required. | Incremental snapshot transfers. | `policy "example-corp" requires each snapshot under its own key, so that deleting it crypto-shreds every copy; use --key-scope snapshot` | | `require_agent_identity` | true or false | A request that says it comes from an agent must carry a token made with `kosa8 agent token create`. | The service, on every request marked as an agent's; and `kosa8 mcp serve`, which refuses to start without a token. | `the org policy requires agents to identify themselves, and this request carries no agent token` | | `require_agent_owner` | true or false | Every agent identity must name the person accountable for it (`kosa8 agent token create --owner`). Implies `require_agent_identity`. | The service, when an identity is minted and on every request from one. | `the org policy requires every agent to name an owner` | ### An approver | Field | Type | Meaning | |---|---|---| | `name` | string | The person's name. Required. | | `email` | string | What makes two keys the same person. Someone listed twice with one email counts once. | | `algorithm` | string | `ed25519` (the default), `ecdsa-p256-sha256` or `ecdsa-p384-sha384`. | | `public_key` | string | Their public key, from their `kosa8 identity show`. A key may be listed only once. | | `roles` | list of strings | Free-form roles a rule can require, such as `security`. | ### An approval rule | Field | Type | Meaning | |---|---|---| | `match` | string | A class (`escalating`, `destructive`, `contained`, `read-only`), one action kind such as `network-create`, or `*`. | | `required` | whole number | How many different people must approve. Zero reads as one. The requester never counts. | | `roles` | list of strings | An approver must hold at least one of these to count. Empty means any approver. | A policy is checked before it is signed. A rule nobody listed could satisfy is refused then, with the count: `approval rule for "network-create" needs 2 distinct approvers with role security, but the profile lists only 1 who qualify — add approvers or lower required`. ### An approved model | Field | Type | Meaning | |---|---|---| | `name` | string | For people reading the policy. Never matched: a file renamed to an approved name is still the wrong file. | | `digest` | string | `sha256:` and 64 lower-case hex digits of the model file. Required. | | `signer` | string | When set, the model must also carry a signature by this public key, made with `kosa8 model sign`. | ### A complete organisation policy ``` { "name": "example-corp", "allowed_registries": ["docker.io", "ghcr.io"], "default_egress": "deny", "require_sandbox": true, "allowed_mcp": ["ghcr.io/example-corp/github-mcp:1.4.2"], "require_fips": false, "approvers": [ {"name": "Asha Rao", "email": "asha@example.com", "public_key": "AoNDqYXXYG2QK5C0P+qk+xFn0a6fxXQlp7dairJCqzg=", "roles": ["security"]}, {"name": "Ben Ortiz", "email": "ben@example.com", "public_key": "PjsMrSBl7Xa0U/NmpPSbq/7sZEz777lQUbrjEzCHenE=", "roles": ["security", "release-manager"]} ], "approval_rules": [ {"match": "escalating", "required": 1}, {"match": "network-create", "required": 2, "roles": ["security"]} ], "allow_unlisted_requesters": false, "require_audit_forwarding": true, "min_audit_retention_days": 400, "labels": { "levels": [ {"name": "UNCLASSIFIED"}, {"name": "CUI", "max_egress": "deny", "mount_paths": ["/Users/Shared/cui"], "destinations": ["remote:r2.example.com/cui-snapshots"]} ], "downgrade_approvals": 2 }, "allowed_models": [ {"name": "qwen2.5-coder-7b-q4", "digest": "sha256:9f2c1e0b7a6d5c4f3e2d1c0b9a8f7e6d5c4b3a2918070605f4e3d2c1b0a99887"} ], "model_recording": "digests", "require_snapshot_encryption": true, "snapshot_key_scope": "snapshot", "require_agent_identity": true, "require_agent_owner": true } ``` `kosa8 policy init` prints a much shorter starting point: ``` $ kosa8 policy init { "name": "example-corp", "allowed_registries": [ "docker.io", "ghcr.io" ], "default_egress": "deny", "require_sandbox": true } ``` ## Part B. The MCP policy MCP (Model Context Protocol) is how an AI client calls tools. A tool server is a program that offers tools; kosa8's gateway runs each third-party server in a sandbox and passes calls through. The MCP policy says which of those calls may happen. ### How a call is decided > Diagram: Five checks left to right. First the scope: the agent's own, or the policy's default. Then the server, which must be listed by exact name. Then deny_tools, where a match denies. Then allow_tools, where no match denies. Then the argument rules, where deny beats ask beats allow. A call has to pass every box. Nothing later can re-open what an earlier one closed. 1. The scope is the one in the agent's verified identity, or the policy's `default_scope`. The caller cannot pick it. 2. The server must be listed in that scope by its exact name. A server nobody listed is denied. 3. A tool matching `deny_tools` is denied, even if an allow pattern also matches. 4. With `allow_tools` set, a tool matching none of them is denied. With it empty, every remaining tool is allowed. 5. Argument rules then look at what the call asks for. Among the rules that match, deny beats ask beats allow. ### The policy | Field | Type | Meaning | |---|---|---| | `name` | string | Required. Audit entries say which policy decided. | | `default_scope` | string | The scope used when the caller has none. It must be one of the scopes below. It lives in the signed document so that nobody governed by the policy can choose a wider scope for themselves. | | `scopes` | list of scopes | The named sets of rules: a team, a job, a risk tier. | | `rules_mode` | string | `enforce` (the default) or `log`. In log mode an argument rule that would deny or ask is recorded as such and the call goes ahead, so a rule set can be tried on real traffic. It softens `rules` only: tool lists and pins still deny. | | `classifier` | object | An optional local scorer for `ask` decisions. Left out, every ask goes to a person. | ### A scope | Field | Type | Meaning | |---|---|---| | `name` | string | Required, and unique in the policy. | | `description` | string | Why the scope exists. Shown by `kosa8 mcp-policy scopes`. | | `servers` | list of server rules | The approved servers. A scope with none denies every server. | ### A server rule | Field | Type | Meaning | |---|---|---| | `server` | string | The server's name as registered with `kosa8 mcp add`, matched exactly. `*` and `?` are refused when the policy is signed. | | `allow_tools` | list of strings | Tool-name patterns that may be called. `*` stands for any run of characters. Empty means every tool the server offers. | | `deny_tools` | list of strings | Tool-name patterns that may never be called. Deny beats allow. | | `rules` | list of argument rules | Decide a call by its arguments, once the tool lists have admitted the tool. | | `pins` | list of pins | The exact tool definitions the organisation reviewed. When any are listed for a server, a tool that is not listed, or whose definition has changed, is withheld, and nothing can be approved locally. | **Wildcards for tools, never for servers** `read_*` approves a family of tools without re-signing the policy each time a server adds one. A wildcard on the server name would let one `*`, or a plausible typo, approve servers nobody reviewed. Tool patterns are case-sensitive: `read_file` does not approve `Read_File`. ### An argument rule | Field | Type | Meaning | |---|---|---| | `id` | string | Names the rule in refusals and audit entries. Optional, but unique when set. | | `tools` | list of strings | Tool-name patterns the rule covers. Required; use `*` for every tool. | | `when` | list of conditions | All must hold. Empty means every call of the matched tools. At most 16. | | `effect` | string | `allow`, `deny` or `ask`. Ask holds the call until a person approves that exact call. | | `reason` | string | Shown to whoever the rule stopped. Write it as the next step. | ### A condition | Field | Type | Meaning | |---|---|---| | `arg` | string | Which argument, as a JSON Pointer: `/repo`, `/files/0/path`. Empty is the whole arguments object. | | `op` | string | The test. One of `exists`, `absent`, `eq`, `ne`, `in`, `not_in`, `prefix`, `suffix`, `contains`, `glob`, `matches`, their `not_` forms (`not_prefix`, `not_suffix`, `not_contains`, `not_glob`, `not_matches`), `lt`, `le`, `gt`, `ge`, `len_le`, `len_ge`, `type`. | | `value` | depends on `op` | What to compare with. Comparison is typed: the string `"1"` never equals the number `1`. | An argument of a type the test cannot handle counts as matching in a deny or ask rule and as not matching in an allow rule, so odd input can only make the outcome stricter. Each operator, with worked examples, is in [Rules for tool calls](https://docs.kosa8.com/guides/tool-rules/). ### A pin | Field | Type | Meaning | |---|---|---| | `tool` | string | One exact tool name. No wildcards. Each tool may be pinned once. | | `digest` | string | `sha256:` and 64 hex digits, copied from `kosa8 mcp pins show SERVER`. It covers the tool's name, description and schemas. | ### The classifier | Field | Type | Meaning | |---|---|---| | `scorer` | string | `local-model`, the only kind. A policy naming any other is refused instead of running without one. | | `endpoint` | string | The address of a model served on this machine. It must be `http://` to `localhost` or a loopback address: tool arguments do not leave the machine. | | `model` | string | Which model to ask, by the name the local model server knows it. Required: a classifier without one is refused when the policy is signed. | | `threshold` | number | How confident an "allow" answer must be to act on: above 0.5 and below 1. There is no default. | | `timeout_ms` | whole number | The longest one question may take, up to 30000. Zero means the scorer's default of two seconds. | The scorer is consulted only for a call a rule answered `ask`. It can spare a person the question; it has no answer that means deny, and anything short of a confident allow leaves the question with the person. ### kosa8's own tools: the server named `kosa8` The name `kosa8` is reserved. In a policy it stands for kosa8's own tools, such as `sandbox_create` and `container_run`. A scope that lists it governs those tools with the same lists and rules as any other server. A scope that does not list it leaves them as they were, because a policy written before this existed said nothing about them. `kosa8 mcp add` refuses to register a third-party server under that name. ### A complete MCP policy ``` { "name": "example-corp", "default_scope": "engineering", "scopes": [ { "name": "engineering", "description": "application engineers", "servers": [ { "server": "github", "allow_tools": ["list_*", "get_*", "search_*", "create_issue", "merge_*"], "deny_tools": ["*_delete", "delete_*"], "rules": [ {"id": "only-acme", "tools": ["create_issue"], "effect": "deny", "when": [{"arg": "/repo", "op": "not_glob", "value": "acme/*"}], "reason": "issues go in acme repositories"}, {"id": "merge-needs-a-person", "tools": ["merge_*"], "effect": "ask", "reason": "merging changes what ships"} ] }, { "server": "kosa8", "deny_tools": ["sandbox_delete", "snapshot_rm"], "rules": [ {"id": "open-network-needs-a-person", "tools": ["sandbox_create"], "effect": "ask", "when": [{"arg": "/egress", "op": "eq", "value": "any"}], "reason": "an open network is reviewed"} ] } ] }, { "name": "contractors", "description": "read-only access to the docs server", "servers": [ { "server": "docs", "allow_tools": ["search", "read_page"], "pins": [ {"tool": "search", "digest": "sha256:3b1f0c9a7e5d2b4861f0a9c8e7d6b5a4938271605f4e3d2c1b0a9f8e7d6c5b4a"}, {"tool": "read_page", "digest": "sha256:7b1f0c9a7e5d2b4861f0a9c8e7d6b5a4938271605f4e3d2c1b0a9f8e7d6c5b4a"} ] } ] } ], "rules_mode": "enforce", "classifier": { "scorer": "local-model", "endpoint": "http://127.0.0.1:7434", "model": "qwen", "threshold": 0.95, "timeout_ms": 2000 } } ``` What that policy answers, for an agent in the default scope: | Call | Answer | Why | |---|---|---| | `github` / `list_issues` | allow | `allow_tools "list_*"` | | `github` / `create_issue` with `repo: acme/api` | allow | No rule matched. | | `github` / `create_issue` with `repo: other/api`, or no `repo` | deny | Rule `only-acme`: `issues go in acme repositories` | | `github` / `merge_pull_request` | ask | Rule `merge-needs-a-person` | | `github` / `delete_repo` | deny | `deny_tools "delete_*"` | | `github` / `fork_repo` | deny | `tool "fork_repo" is not in the allowlist for server "github" in scope "engineering" …` | | `slack` / anything | deny | `MCP server "slack" is not approved for scope "engineering"; approved servers: github, kosa8 — ask your admin to add it to the org policy` | | `kosa8` / `sandbox_delete` | deny | `deny_tools "sandbox_delete"` | | `kosa8` / `sandbox_create` with `egress: any` | ask | Rule `open-network-needs-a-person` | An agent in `contractors` may call the two pinned `docs` tools and no other server. That scope does not list `kosa8`, so kosa8's own tools are not governed for it. ## Part C. Signing, pinning, installing > Diagram: The organisation keeps a private signing key and uses it to sign the policy file. Each machine has the public half pinned in policy-trust.pub. The machine verifies the policy against the pinned key on every decision. If the signature matches, the policy is enforced. If the file was edited, deleted, or signed by another key, kosa8 fails closed and refuses. The pinned key is what makes a missing policy a refusal instead of a return to "no rules". 1. The organisation makes one signing key and keeps the private half. 2. It signs each policy with it. The signed file carries the rules, the public key and the signature. 3. On each machine the public half is pinned, once. 4. kosa8 verifies the installed policy against the pinned key before it uses it: the organisation policy each time a rule is consulted, the MCP policy each time a gateway starts. 5. A policy that verifies is enforced. 6. One that was edited, removed or signed by another key is not ignored: kosa8 refuses the action and says why. ### Make the key ``` $ kosa8 policy keygen -o org.key public: kQ+JPoaWWs9JC1Nic/sRli1nJOAgQFAmmm+xtD0oEeo= private key written to org.key (keep it secret) ``` `-o` writes the private key to that file and the public key to `org.key.pub`. Without it both are printed. ### Write and sign both documents ``` $ kosa8 policy init > policy.json # a template to edit $ kosa8 policy sign org.key policy.json > policy.signed.json $ kosa8 mcp-policy init > mcp.json $ kosa8 mcp-policy sign org.key mcp.json > mcp.signed.json ``` Both `sign` commands check the document first and refuse one that could never work, with `refusing to sign:` and the reason. `kosa8 policy init` also takes `--require-fips` and `--require-snapshot-encryption`. ### Pin the key, then install ``` $ kosa8 policy trust org.key.pub trusted policy signer set $ kosa8 policy install policy.signed.json installed and activated policy "example-corp" $ kosa8 mcp-policy install mcp.signed.json installed MCP policy "example-corp" (2 scope(s)) servers not listed in a scope are now denied ``` `trust` takes the key itself or a file containing it. Pin before you install. `kosa8 mcp-policy install` refuses otherwise: ``` no trusted policy signer is pinned, so any key would be accepted here — pin your org's public key first with `kosa8 policy trust ` ``` ### Check what is in force ``` $ kosa8 policy show # the organisation policy and its signer $ kosa8 mcp-policy show # scopes, servers, allow and deny lists $ kosa8 mcp-policy scopes # the scopes, with the default marked $ kosa8 mcp-policy check github list_issues $ kosa8 mcp-policy check --scope contractors github list_issues $ kosa8 mcp-policy check --args '{"repo":"other/api"}' github create_issue $ kosa8 mcp-policy events --denied # the decisions, for a SIEM ``` | Command | What it does | |---|---| | [`kosa8 policy keygen`](https://docs.kosa8.com/reference/policy-keygen/) | Makes a signing key pair. `-o FILE` writes it to files. | | [`kosa8 policy init`](https://docs.kosa8.com/reference/policy-init/) | Prints a template organisation policy. | | [`kosa8 policy sign KEYFILE PROFILE.json`](https://docs.kosa8.com/reference/policy-sign/) | Prints the signed policy. | | [`kosa8 policy trust PUBKEY_OR_FILE`](https://docs.kosa8.com/reference/policy-trust/) | Pins the signer on this machine, for both documents and for kits. | | [`kosa8 policy install SIGNED.json`](https://docs.kosa8.com/reference/policy-install/) | Verifies against the pinned key and makes the policy active. | | [`kosa8 policy show`](https://docs.kosa8.com/reference/policy-show/) | Shows the active organisation policy. With none: `no policy installed (everything allowed)`. | | [`kosa8 mcp-policy init`](https://docs.kosa8.com/reference/mcp-policy-init/) | Prints a template MCP policy with two scopes. | | [`kosa8 mcp-policy sign KEYFILE POLICY.json`](https://docs.kosa8.com/reference/mcp-policy-sign/) | Prints the signed MCP policy. Use the same key. | | [`kosa8 mcp-policy install SIGNED.json`](https://docs.kosa8.com/reference/mcp-policy-install/) | Verifies and activates it. Refuses when no key is pinned. | | [`kosa8 mcp-policy show`](https://docs.kosa8.com/reference/mcp-policy-show/) | Shows the active MCP policy, or why it cannot be used. | | [`kosa8 mcp-policy scopes`](https://docs.kosa8.com/reference/mcp-policy-scopes/) | Lists the scopes the policy defines. | | [`kosa8 mcp-policy check SERVER TOOL`](https://docs.kosa8.com/reference/mcp-policy-check/) | Asks whether a scope may call a tool, and records the decision. `--scope` tries another scope; `--actor` says who is asking. Without `--args` only the tool lists are consulted; with `--args JSON` the server's argument rules are applied to those arguments as the gateway would. Exits non-zero on a denial. | | [`kosa8 mcp-policy events`](https://docs.kosa8.com/reference/mcp-policy-events/) | Exports the decisions. `--format jsonl`, `cef` or `table`; `--denied`; `-n` for the most recent few. | Making a key, signing, pinning and installing need the Governance subscription. Enforcing an installed policy does not. ## Part D. When a policy is missing or wrong Both documents fail closed, with one deliberate difference in the second row. | Situation | Organisation policy | MCP policy | |---|---|---| | No key pinned, no policy | nothing restricted The machine is unconfigured. | nothing restricted Every registered server is reachable. | | Key pinned, policy file missing | refuses `a trusted policy signer is pinned but no policy is installed (was it deleted?); re-install with `kosa8 policy install | denies every third-party server `a trusted policy signer is pinned but no MCP policy is installed (was it deleted?); re-install it with `kosa8 mcp-policy install kosa8's own tools still work | | Policy file edited | refuses `installed policy failed verification: signature does not match profile (tampered or wrong key)` | denies everything, kosa8's own tools included. `the installed MCP policy failed verification: signature does not match MCP policy (tampered or wrong key); re-install a policy signed by your org with `kosa8 mcp-policy install | | Policy signed by another key | refuses `installed policy failed verification: profile is signed by an untrusted key` | denies everything `… failed verification: MCP policy is signed by an untrusted key …` | The organisation policy is read from disk each time a rule is consulted, so a change takes effect at once. A tool gateway (`kosa8 mcp serve`) reads the MCP policy once, when it starts, so that the rules do not change under a running agent: a newly installed, edited or deleted MCP policy applies from the next time the gateway starts. "Refuses" for the organisation policy means each place that consults it stops, with its own words in front of the reason above: | Where | What it says | |---|---| | The service, at start | `kosa8d: org policy present but failed verification, refusing to start: …` | | Fetching an image | `org policy present but failed verification, refusing to pull: …` | | Running on the shared engine | `org policy present but failed verification, refusing to run: …` | | Creating a sandbox | `org policy present but failed verification, refusing to proceed: …` | | Starting tool servers | `org policy present but failed verification, refusing to start MCP servers: …` | | An agent with no token | `org policy present but failed verification, so an unidentified agent cannot be served: …` | **Why a missing MCP policy does not stop kosa8's own tools** An organisation can pin a key for its organisation policy and never write an MCP policy. Third-party servers are denied there. Treating that absence as a denial of `sandbox_create` and the rest would take kosa8 away from every such organisation. The cost is stated plainly: deleting `mcp-policy.json` lifts the rules it held for kosa8's own tools. A policy that is present and does not verify denies both. For actions that must not be self-authorised, use signed approvals (`approvers` above), which do not live in that file. ## Part E. Checking a machine `kosa8 doctor --compliance` checks this machine against the hardening baseline: whether a policy is installed and what it requires, among other controls. It reports; the fields above are what enforce. See the [doctor](https://docs.kosa8.com/reference/doctor/) reference page. #### What this does not do - **Enforcement is on the machine being governed.** kosa8 checks the policy with code and files that belong to the user it constrains. Someone who can delete both the policy and the pinned key, `~/.kosa8/policy-trust.pub`, returns the machine to "no rules". Provision that file out of band and write-protect it, for example owned by an administrator account. - **With no key pinned, a signature proves little.** `kosa8 policy install` then accepts a policy signed by any key, including one a developer made. Pin first. - **No central distribution.** There is no admin console, and no SSO, SAML or SCIM. A policy is a signed file you deliver to each machine yourself, and a new version reaches a machine when someone installs it there. - **`default_egress` is a default.** A sandbox created with an explicit network rule keeps that rule. To require review of a wider one, add an approval rule for escalating actions. - **Tool rules see names and arguments, not meaning.** A deny rule on `/path` does nothing about a server that also accepts `/file`, and nothing here reads the text a tool returns. - **Deleting the MCP policy lifts its rules on kosa8's own tools**, as described above. ## Where to go next [`Company rules` The walkthrough: make a key, sign a policy, install it.](https://docs.kosa8.com/guides/governance/) [`Rules for tool calls` Every operator, with examples, and how ask and approve work.](https://docs.kosa8.com/guides/tool-rules/) [`Agent identity` Tokens, scopes, and what `require_agent_identity` changes.](https://docs.kosa8.com/guides/agent-identity/) [`The audit log` Where every decision described here is written down.](https://docs.kosa8.com/guides/audit/) --- Source: https://docs.kosa8.com/guides/policy-reference/ # The audit log kosa8 writes down every action that changes something: who asked, what they asked for, and whether it was allowed. Each entry is sealed to the one before it, so an entry that is edited or removed later shows. This page explains what is in the log, what is deliberately left out, and how to answer questions with it. ## What gets an entry The log is one file, `~/.kosa8/audit.jsonl`, with one JSON object per line. Only the background service writes to it. #### Written down - Every request to the service that changes state: creating, running, pulling, deleting, snapshotting, restoring. Refused requests too, with their status. - Every request a sandbox makes through its egress broker, allowed or refused. - Every connection or DNS lookup the network guard refused. - Every tool-call decision the MCP gateway made, and what each allowed call returned, as a digest. - Signed approvals: requested, granted, refused and used. - An agent token that was refused. #### Left out, on purpose - Reads. A log that is 99% list calls is a log nobody reads. - Tool arguments and results. Their SHA-256 digests are recorded instead. - Prompts and model output, unless the organisation policy turns recording on. - Environment variables. That is where credentials are passed on purpose. - Query strings and request bodies of brokered requests. - API keys, placeholders and tokens. The log is forwarded to other systems and kept for years, unencrypted. A digest lets someone who holds the original arguments prove they are the ones that were sent, without putting them where everyone can read them. ## One action, several entries > Diagram: Four things happen and each writes an entry. The agent calls the sandbox_run tool, which writes an mcp.decision entry. The service runs the command, which writes a sandboxes.run entry with the image, the command and its digest. The command fetches a package through the broker, which writes sandbox.egress.allow. The command tries another host, which writes sandbox.egress.deny. Each layer records what it decided. Together they answer "what did the agent do?" 1. The tool call is decided and recorded. This entry is written when the MCP policy governs the tool; a third-party tool gets one on every call. 2. The request reaches the service, which records the action with the image and command. 3. Inside the sandbox, a request to an allowed host is recorded with where it went. 4. A request to anywhere else is refused on your side of the boundary, and the attempt is recorded. ## The shape of an entry ``` { "seq": 189, // its place in the log; rises by one "time": "2026-10-14T02:26:44.569612Z", // UTC "actor": "guest", // who did it "action": "sandbox.egress.allow", // what was done "target": "broker-76849", // to what: here, the sandbox "detail": "{\"kind\":\"route\",\"host\":\"httpbin.org\",\"port\":443,\"method\":\"GET\",\"path\":\"/headers\",\"route\":\"echo\",\"status\":200,\"bytes_in\":239}", "status": 200, // the outcome "format": 2, // the entry version "refs": { // the same facts, to search by value "sandbox": "broker-76849", "egress": {"kind": "route", "allowed": true, "host": "httpbin.org", "port": 443, "method": "GET", "path": "/headers", "route": "echo", "status": 200} }, "prev_hash": "b9b0f86a08f0acc8ecaa0630852f0db6def2ce2a207c33709162520f60db572e", "hash": "9cf2cd1bfa024769533d35abae8b01ea5d9420f2abf4a7e4169d1d9b064a87af" } ``` The comments are for this page; the file holds plain JSON, one entry per line. | Field | Meaning | |---|---| | `seq` | The entry's number. A gap means an entry was removed. | | `time` | When it was written, in UTC. | | `actor` | Who did it. See the next table. | | `action` | What was done, as a stable name built from the request: `sandboxes.create`, `sandboxes.run`, `snapshots.restore`, `agents.create`, `pull`. Requests through the Docker-compatible API read `containers.create`, `containers.start` and so on. | | `target` | What it was done to: a sandbox, a snapshot, a container, or `SERVER/TOOL` for a tool call. Left out when the name is in the request body, as for `sandboxes.create`. | | `detail` | A line for a person to read. For network entries it is itself JSON. | | `status` | The outcome as an HTTP status: `200` or `201` for done, `401` for a refused token, `403` for refused by a rule. | | `format` | The entry version. `2` for every entry written now; absent on entries from before versions existed. | | `refs` | What the entry points at, by value, so a program can find it without parsing `detail`. Left out when there is nothing to name. | | `prev_hash` | The `hash` of the entry before this one. | | `hash` | This entry's seal. It covers every field above, `refs` included. | ### Actors | Actor | Who that is | |---|---| | `human` | The `kosa8` command line. | | `agent` | An AI client, through `kosa8 mcp serve`. | | `tool` | Another program on the service's socket, such as the Docker CLI. Also any request that does not say who it is. | | `guest` | Code inside a sandbox, as observed from your side: a network request it made or was refused. | | `policy` | A step the service took because the signed organisation policy required it, such as encrypting a snapshot the moment it was taken. | **"agent" is a claim unless the entry names one** `human`, `agent` and `tool` are what the caller said about itself. The service has verified an agent only when the entry also carries `refs.agent`, which means the request presented that agent's token. An entry marked `agent` with no `refs.agent` is an unverified claim. See [Agent identity](https://docs.kosa8.com/guides/agent-identity/). ### What `refs` can carry | Key | What it names | |---|---| | `sandbox` | The sandbox the entry is about. | | `snapshot` | A snapshot, by id. | | `agent` | The agent identity the service verified: `id`, `name`, `scope`, and the `owner` accountable for it (`name`, `email`, `key`) when one was given at mint time. | | `egress` | One request through a sandbox's broker: `kind`, `allowed`, `host`, `port`, `method`, `path`, `route`, `status`, `suppressed`. | | `mcp` | One tool-call decision: `phase`, `server`, `tool`, `scope`, `allowed`, `rule`, the policy that decided, and digests of the `arguments` and `result`. | | `session` | One run of the MCP gateway or the model gateway, tying its entries together. | | `parent_seq` | An earlier entry this one continues: the call a tool result answers, the approval a request used. | | `exchange` | One request the model gateway answered, by digest. | | `model` | The digest of the model that ran. | | `labels` | The classification a sandbox or snapshot carries. | | `approval_request`, `approvals` | A signed approval request and the approvals presented with it. | | `files` | Files by path, size and digest: an encrypted snapshot's parts, a wiped disk, a capture. | | `seal_manifest`, `signer` | The exact sealed snapshot an entry is about, and whose key sealed it. | | `policy_digest` | The organisation policy in force, by digest: the value `kosa8 policy show` reports. Every entry written while a signed policy is installed and verifies carries it, so you can tell which version of the rules applied to any line. An approval names the policy its request was made under. An entry without it was written with no valid policy installed. | Three you will meet often: ``` // refs.agent — on every request a verified agent made "agent": {"id": "agt_8c7f67b7892f", "name": "reviewer", "scope": "eng", "owner": {"name": "Jane Doe", "email": "jane@example.com", "key": "3f9a…"}} // refs.egress — a refused request through the broker "egress": {"kind": "connect", "allowed": false, "host": "evil.example", "port": 443} // refs.mcp — a tool call a rule denied "mcp": {"phase": "call", "server": "echo", "tool": "echo", "scope": "eng", "allowed": false, "rule": "eng/echo rules \"no-secrets\"", "policy": "corp", "policy_digest": "sha256:56f7e2bd15b2dfb4d8a4f7a4d73487311ea58a45e6c8019ec61beb1f3ca29e59", "arguments": "31ba7019834990d9adcf1296cfb715a6088eff3e55e8eb920aff687716c2893b", "server_sandbox": "mcp-echo"} ``` `refs.egress.kind` is `route` for a request the broker added a credential to, `http` for a plain request, and `connect` for an encrypted tunnel, where the broker sees the host and port and nothing inside. `refs.mcp.phase` is `expose` (the tool was offered to, or withheld from, the agent when the gateway started), `call`, `result`, or `check` for `kosa8 mcp-policy check`. ## Commands in the log "Something ran in sandbox dev" is not much of a record. Every run and exec entry says what ran: the image, the command line, and the SHA-256 of the exact arguments. ``` {"seq":272,"time":"2026-10-14T02:32:55.374227Z","actor":"human","action":"sandboxes.run", "target":"docdemo", "detail":"image=alpine cmd=\"uname -r\" cmd_sha256=63288e3ac7d977d241c72d374e6c340f684732e525ba7a8ee5f5dfacc3f17dd1", "status":200,"format":2,"prev_hash":"09494244…","hash":"c6576089…"} ``` A command line is the commonest place a secret is typed by mistake, so parts shaped like a credential are replaced before the line is written: #### What was run ``` curl -H "Authorization: Bearer abc.def.ghi" \ https://api.example.com/v1/deploy psql --password=hunter2 -c "select 1" env API_KEY=sk-live-0123456789abcdefghij \ node app.js ``` #### What the entry holds ``` curl -H Authorization: Bearer [redacted] https://api.example.com/v1/deploy psql --password=[redacted] -c select 1 env API_KEY=[redacted] node app.js ``` (Each command is one line in the entry; they are wrapped here to fit.) Lines longer than 512 bytes are cut and end in `…`. `cmd_sha256` is computed from the real arguments, before redaction, so two commands that differ only in a redacted value still have different digests, and someone who holds the command can prove it is the one that ran. **Redaction is a courtesy, not a guarantee** kosa8 recognises the usual shapes: a value after a name such as `password`, `token`, `secret` or `api_key`, a `Bearer` or `Basic` value, and well-known key prefixes. A secret that looks like an ordinary word is written as typed. Pass secrets through the environment, which is never recorded, or keep them out of the sandbox entirely with a [brokered credential](https://docs.kosa8.com/guides/credentials/). ## How the log shows tampering > Diagram: Three entries in a row. Each carries the hash of the one before it as prev_hash, and its own hash. Every hash is an HMAC under a key in audit.key. A separate record, audit.head, holds the number and hash of the last entry. Editing entry 272 makes its hash wrong; entry 273 then no longer links to it; cutting entries from the end disagrees with the head record. Three checks: each entry's own seal, its link to the one before, and the recorded end. 1. Every entry has a `hash` that covers all of its fields. 2. The next entry carries that hash as its `prev_hash`, so the two are linked. 3. And so on: every entry depends on every entry before it. 4. The hash is a keyed one (HMAC-SHA256). The key is made once, on first use, in `~/.kosa8/audit.key`, readable only by you. Without it the hashes cannot be recomputed. 5. After each entry the service records where the chain ends, in `~/.kosa8/audit.head`, sealed with the same key. 6. Change an entry and its hash stops matching its contents. 7. Remove one and the next entry's `prev_hash` points at nothing, and the numbering has a gap. 8. Cut entries off the end and what is left is a perfect, shorter chain. The head record is what shows it is shorter than it was. ``` $ kosa8 audit verify audit chain intact: 17 entries verified ``` When something is wrong, `verify` names the first entry that fails and exits with a non-zero status: | What was done to the log | What `kosa8 audit verify` prints after `audit chain BROKEN at entry N:` | |---|---| | An entry was edited | `entry hash mismatch: this entry's contents were altered` | | An entry was changed or removed, seen from the next one | `previous-hash mismatch: an earlier entry was changed or removed` | | An entry was removed and the links patched | `sequence gap: an entry was removed` | | Entries were cut from the end | `entries are missing from the end: the log reaches 250, but 273 were recorded` | | The end was cut and `audit.head` deleted | `the recorded end of the chain is missing: audit.head was removed, so entries may have been cut from the end` | | The last entry was replaced | `the last entry is not the one that was recorded: the end of the log was rewritten` | If `audit.key` is removed from a log that has entries, kosa8 does not make a new one and carry on. It stops writing entries, because an entry written without the key is one anybody could have written. ### Signatures anyone can check The keyed hash proves an entry to whoever holds `audit.key`, and that same key is what would let them forge one. So every entry is also signed with a private key that stays on this machine (`~/.kosa8/audit.sign.key`), and the public half is enough to check it. An auditor, a customer or a SIEM can verify a record, or an [evidence package](https://docs.kosa8.com/reference/evidence-export/), without being trusted with anything. ``` $ kosa8 audit key SHA256:1e8488f24da196ef… ed25519 (current) public key (DER, base64): MCowBQYDK2VwAyEA… $ kosa8 audit verify audit chain intact: 17 entries verified 17 signed, by SHA256:1e8488f24da196ef… (`kosa8 audit key` to pin) ``` Send the key id to whoever will check your records through a different channel from the records, such as a ticket or a signed email, so they pin the key they expect rather than the one a package names. A hardware key (Secure Enclave, TPM) signs with ECDSA P-256 and is checked the same way. Entries written before signing existed keep their keyed hash and verify as they always did. What a signature cannot show by itself is that nothing was cut from the end; the head record still covers that. ## Reading and exporting ``` $ kosa8 audit ls SEQ TIME ACTOR ACTION TARGET STATUS 21 02:16:29 guest sandbox.egress.allow brk2 200 ``` | Command | What it does | |---|---| | [`kosa8 audit ls`](https://docs.kosa8.com/reference/audit-ls/) | Shows recent entries as a table. `-n`, `--limit` sets how many (default 25; `0` for all). `--actor` keeps one actor from those. Long targets are shortened in the table. | | `kosa8 audit ls --json` | The same entries as a JSON array, complete: `detail`, `refs` and the hashes. Use this with `jq`. | | [`kosa8 audit verify`](https://docs.kosa8.com/reference/audit-verify/) | Walks the chain and confirms every entry is authentic and none is missing. `--archive` also verifies the sealed segments retention moved old entries into. | | [`kosa8 audit export`](https://docs.kosa8.com/reference/audit-export/) | Writes every entry. `--format jsonl` (the default) or `cef`, the ArcSight format many SIEMs read. `--label` exports only entries about one classification. | | [`kosa8 audit forward`](https://docs.kosa8.com/reference/audit-forward/) | Sends entries each configured sink has not yet received, in order, with their sequence numbers and hashes. `--sink NAME` for one sink; `-f`, `--follow` to keep going. Sinks (syslog, Splunk HEC, HTTPS) are configured in `~/.kosa8/audit-config.json`, and the service forwards on its own while it runs. | | [`kosa8 audit status`](https://docs.kosa8.com/reference/audit-status/) | Shows where entries are forwarded, how far each sink has got, and retention. | | [`kosa8 audit prune`](https://docs.kosa8.com/reference/audit-prune/) | Applies the retention policy now: old entries are sealed and removed from the live log, and the chain still verifies afterwards. `--dry-run` reports without changing anything. | | [`kosa8 evidence export`](https://docs.kosa8.com/reference/evidence-export/) | Writes a signed package of the entries about a `--sandbox`, `--snapshot`, `--session` or time range (`--since`, `--until`), with the stretch of chain around them. `-o` names the file. | | [`kosa8 evidence verify PACKAGE`](https://docs.kosa8.com/reference/evidence-verify/) | Checks such a package using nothing but the package: no service, no kosa8 state, no network. `--pubkey` pins who exported it; `--audit-key` also checks that the machine's key wrote every entry. | Whoever you send a package to does not need kosa8 to check it. `kosa8 evidence verify --web verifier.html` writes the same verification as a single HTML file. They open it in any current browser and drop the package on it: the `.tar`, the package folder, or a zip of either. The page reads the package inside the browser and gives the verdict `kosa8 evidence verify` gives without `--audit-key`, check by check, naming each file that fails and why. It makes no network requests, and its Content-Security-Policy forbids them, so it works from a saved copy on a machine with no network. Send the page alongside the package, and send your public key through a different channel so they can pin it in the page. Checking the keyed hashes still needs the audit key and the CLI. ## Network entries are rate-limited A sandbox can open connections far faster than a sealed log should grow, so network entries have a budget. Nothing is lost silently: what was not written individually is counted. | Source | Budget | Where the count goes | |---|---|---| | Requests through the broker, in three budgets of their own: refused, credentialed (route), and other allowed | 120 entries a minute for each kind, for each sandbox | `refs.egress.suppressed` on the next entry of the same kind: how many requests since the last recorded one had no entry of their own. | | Connections and DNS lookups the network guard refused | 20 entries a minute for each sandbox | `suppressed` inside the next denial's `detail`, or in a final summary entry when the sandbox's network closes. | Ordinary link chatter a Linux machine sends at every boot (IPv6 router messages, multicast) is dropped and counted, never audited. `kosa8 sandbox egress NAME` shows the counts. ## Answering questions with the log These use `kosa8 audit ls --json -n 0`, which prints every entry as one JSON array, and the `jq` tool to pick from it. ### Which agent did this? ``` $ kosa8 audit ls --json -n 0 | jq -r '.[] | select(.refs.agent != null) | [.seq, .refs.agent.name, .refs.agent.id, .action, .target] | @tsv' 264 reviewer agt_8c7f67b7892f mcp.decision echo/upload 266 reviewer agt_8c7f67b7892f sandboxes.containers mcp-echo ``` To find requests that only claimed to be an agent, ask for the opposite: `select(.actor == "agent" and .refs.agent == null)`. ### Who was accountable, and under which policy? ``` $ kosa8 audit ls --json -n 0 | jq -r '.[] | select(.refs.agent != null) | [.seq, .refs.agent.name, (.refs.agent.owner.name // "-"), (.refs.policy_digest // "-"), .action] | @tsv' ``` Compare the digest with `kosa8 policy show` to see whether an entry ran under the policy installed now or an earlier version. ### What did a sandbox try to reach? ``` $ kosa8 audit ls --json -n 0 | jq -r '.[] | select(.target == "broker-76849" and (.action | startswith("sandbox.egress."))) | (.detail | fromjson) as $d | [.seq, .action, ($d.host // $d.name // $d.dst), ($d.port // "-"), ($d.path // "-"), ($d.reason // "-")] | @tsv' 189 sandbox.egress.allow httpbin.org 443 /headers - 191 sandbox.egress.deny httpbin.org 443 /anything path not allowed on route 195 sandbox.egress.allow example.com 80 / - 196 sandbox.egress.deny neverssl.com 80 / host not in allow-list 197 sandbox.egress.deny evil.example 443 - host not in allow-list 199 sandbox.egress.deny example.com 53 - name not in egress policy deny 201 sandbox.egress.deny 192.168.127.254 22 - not in egress policy deny ``` For these entries `detail` is JSON, which is why the filter parses it. Requests through the broker carry `host`; refusals by the network guard carry `dst`, and `name` for a DNS lookup. ### Which tool calls were refused, and by which rule? ``` $ kosa8 audit ls --json -n 0 | jq -r '.[] | select(.action == "mcp.decision" and .refs.mcp.phase == "call" and (.refs.mcp.allowed | not)) | [.seq, (.refs.agent.name // "-"), .target, (.refs.mcp.rule // "(no rule: default deny)")] | @tsv' 250 reviewer echo/echo eng/echo rules "no-secrets" 251 reviewer echo/echo schema 252 reviewer kosa8/sandbox_delete eng/kosa8 deny_tools "sandbox_delete" 253 reviewer echo/echo eng/echo rules "deploys-need-a-person" ``` A rule reads `SCOPE/SERVER`, then which list fired, then the pattern or rule id. `schema` means the arguments did not fit the tool's pinned schema. A call held for a person's approval appears here too; add `.detail` to the list to read the reason. ### What commands ran in a sandbox? ``` $ kosa8 audit ls --json -n 0 | jq -r '.[] | select(.target == "docdemo" and (.action | test("^sandboxes\\.(run|exec|start|start-interactive)$"))) | [.seq, .time, .actor, .detail] | @tsv' 272 2026-10-14T02:32:55.374227Z human image=alpine cmd="uname -r" cmd_sha256=63288e3ac7d977d241c72d374e6c340f684732e525ba7a8ee5f5dfacc3f17dd1 ``` ### Was anything auto-allowed by the scorer? ``` $ kosa8 audit ls --json -n 0 | jq -r '.[] | select(.action == "mcp.decision" and ((.detail // "") | contains("auto-allowed by"))) | [.seq, (.refs.agent.name // "-"), .target, .detail] | @tsv' ``` When a policy configures a local scorer and it lets a call through in place of asking a person, the decision's `detail` says so, with the scorer, its confidence and the threshold. No output means it never happened. A call a person approved reads `approved by a person (held call ID)` in the same place. #### What this does not do - **Tamper-evident, not tamper-proof.** The log and the key that seals it are files on the same computer. Someone with full control of that computer can rewrite the log and reseal it, and nothing on the machine can prevent that. Forward the log off the machine (`kosa8 audit forward`), and keep a copy of `audit.key` somewhere else so an export can be checked against it. - **Attribution is verified only for agents with a token.** The service's socket belongs to your user, and any program running as you can call it and say it is a person. What the log guarantees is narrower: an entry with `refs.agent` came from a holder of that agent's token. - **Arguments, results and prompts are not kept.** Only their digests are. The log can prove what was sent if you have it from somewhere else; it cannot show you what was sent. - **Inside an encrypted tunnel the broker records the host and port only.** The method and path of a request appear for brokered credentials and plain HTTP, not for `connect` entries. - **Not every network event has its own entry.** Past the budgets above, events are counted in `suppressed`. - **Redaction of command lines is a best effort**, as described above. - **Reads are not logged.** The log does not show who listed sandboxes or read a file. ## Where to go next [`Agent identity` Give each agent a token so its entries name it.](https://docs.kosa8.com/guides/agent-identity/) [`Network rules` What produces the allow and deny entries.](https://docs.kosa8.com/guides/network/) [`Rules for tool calls` The rules behind every `mcp.decision`.](https://docs.kosa8.com/guides/tool-rules/) [`Policy reference` Requiring forwarding and a minimum retention.](https://docs.kosa8.com/guides/policy-reference/) --- Source: https://docs.kosa8.com/guides/audit/ # How kosa8 works kosa8 is three programs and a boundary. A command-line tool you type into, a background service that does the work, and a small helper inside every virtual machine. Everything an AI assistant does happens on the far side of the boundary; everything that decides what it may do stays on yours. ## The whole thing in one picture > Diagram: On your computer: the kosa8 command and an AI client talk to the kosa8d service over a private socket. kosa8d holds the policy, the audit log and the network guard. Each sandbox is a separate virtual machine with its own kernel, reached only through a control channel and a filtered network. What runs where. The dashed lines are trust boundaries; the moving lines are the only ways across them. 1. You type a command, or an AI client calls a tool. Both arrive at the same place. 2. `kosa8d`, a service running as your user, receives it over a socket only you can open. 3. Before anything happens it is checked against policy, attributed to whoever asked, and written to the audit log. 4. The work itself runs in a sandbox: a real virtual machine with its own kernel. Nothing in it can see your files except the one folder you shared. 5. Every packet the machine sends passes the network guard, which lives on your side where nothing inside the machine can reach it. 6. API keys stay with the broker. The assistant gets a stand-in that is worthless anywhere else. 7. Requests to the hosts you named go out, with the real key added on the way. 8. Anything else is refused before it leaves your computer, and the attempt is recorded. ## The three programs **kosa8**The command-line tool. It holds no state and does no work of its own: every command is a request to the service. That is why the Mac app, the CLI and an AI client always agree about what is running — they are all asking the same thing. **kosa8d**The background service. It starts and stops virtual machines, enforces policy, runs each sandbox's network, and is the only writer of the audit log. It runs as you, without administrator rights. **kosa8-guestd**A small helper inside each virtual machine. It starts containers when the service asks. It is on the untrusted side: nothing the service decides depends on it telling the truth. ## Two kinds of place to run things kosa8 can run a container in two places, and the difference matters. #### The shared engine `kosa8 run` and the Docker-compatible API use one always-warm virtual machine for all your everyday containers. It starts in milliseconds and behaves like Docker. Containers in it are separated from your Mac by a VM, and from each other by ordinary container isolation. #### A sandbox `kosa8 sandbox` and `kosa8 agent run` give the work a virtual machine of its own: its own kernel, its own disk, its own private network with its own rules. This is where an AI assistant belongs, and where save, copy and undo work. **Want everything in a sandbox?** An organisation can require it. With `require_sandbox` in the signed policy, the shared engine refuses to run anything and every container gets a machine of its own. See [Policy reference](https://docs.kosa8.com/guides/policy-reference/). ## What crosses the boundary A sandbox is useful only if the list of things that cross into and out of it is short. This is the whole list. | Crossing | Direction | Who controls it | |---|---|---| | Control channel | Your side → sandbox | The service only. It is not a network connection, and the sandbox cannot open one back. | | Shared folder | Both | You, when you create the sandbox. Exactly the folders you named; writable unless you add `:ro`. | | Network | Sandbox → world | The network guard, by the sandbox's rules. See [Network rules](https://docs.kosa8.com/guides/network/). | | Credentials | Never | They stay with the broker. See [Keep API keys out of the sandbox](https://docs.kosa8.com/guides/credentials/). | | Disk and memory | Neither | Each sandbox has its own. A snapshot is a file on your side that the sandbox cannot read. | ## What happens when you create a sandbox > Diagram: Five steps left to right: check policy, clone the disk, start the network, boot the virtual machine, ready. The network and its rules exist before the machine boots, so there is no moment in which a sandbox is running without them. ``` $ kosa8 sandbox create dev --egress-allow registry.npmjs.org sandbox dev ready in 737ms (32 GB disk) $ kosa8 sandbox pull dev alpine $ kosa8 sandbox run dev alpine uname -r 6.18.35 $ kosa8 sandbox egress dev policy: allow:registry.npmjs.org (enforced on the host) frames: 3 passed, 0 denied, 1 dropped as link noise dns: 2 answered, 0 refused ``` ## Where things are kept All of kosa8's state is in one folder in your home directory, `~/.kosa8`, readable only by you. | Path | What it is | |---|---| | `run/kosa8.sock` | The socket the command talks to the service over. | | `sandboxes/` | Each sandbox's disks and record. | | `snapshots/` | Saved machines. | | `audit.jsonl`, `audit.key` | The audit log and the key that makes it tamper-evident. | | `policy.json`, `mcp-policy.json` | Your organisation's signed rules, if it has any. | | `agents.json` | Agent identities. Tokens are stored as digests, never as themselves. | | `mcp-servers.json`, `mcp-pins.json` | Third-party tool servers and the definitions they are pinned to. | | `kits/` | Your own sandbox kits. | | `log/` | The service's log and each sandbox's console. | ## Where to go next [`Securing an agent` The layers between an AI assistant and the things it could damage.](https://docs.kosa8.com/guides/secure-agents/) [`Network rules` What a sandbox can reach, and where that is enforced.](https://docs.kosa8.com/guides/network/) [`The audit log` What is written down, and how to prove nobody edited it.](https://docs.kosa8.com/guides/audit/) [`For developers` The source layout, the API, and how to build and test.](https://docs.kosa8.com/guides/developers/) --- Source: https://docs.kosa8.com/guides/how-it-works/ # For developers This page is for two kinds of reader: someone reading or changing kosa8's source, and someone driving kosa8 from their own program. It covers how to build and test, what each package is for, the path a request takes through them, the service's HTTP API, and the rules the code is written to. If you have not read [How kosa8 works](https://docs.kosa8.com/guides/how-it-works/), start there. It explains the three programs and the boundary this page assumes. ## Build kosa8 is written in Go; `go.mod` names the version it needs (Go 1.26). Running sandboxes needs macOS on Apple silicon, or Linux with KVM, `cloud-hypervisor` and `virtiofsd`. Everything else, including the unit tests, builds anywhere Go does. ``` $ make build # bin/kosa8 and bin/kosa8d $ make guest # build the guest image and install it to ~/.kosa8/guest $ make test # go test ./... $ make test-race # go test -race ./... $ make lint # go vet ./... $ make cross # build the guest helper and every package for Linux arm64 and amd64 $ make check # fmt, lint, test and cross: everything CI enforces ``` **Signing on macOS** `kosa8d` embeds Apple's Virtualization.framework, which only works in a binary that carries the right entitlement. `make build` therefore runs `scripts/sign-macos.sh bin/kosa8d` on a Mac. With `APPLE_SIGN_IDENTITY` set it makes a real Developer ID signature; otherwise it signs ad hoc, which is enough to run locally. On a Mac, a `kosa8d` built with plain `go build` and left unsigned lacks that entitlement. `make cross` matters because the macOS build cannot see two things: the files tagged for Linux, and the guest helper, which is Linux-only. `make fips` builds against Go's FIPS 140-3 module and `make test-fips` runs the tests under it. ## The source layout | Path | What it is for | |---|---| | `cmd/kosa8` | The command-line program. A few lines that call `internal/cli`. | | `cmd/kosa8d` | The background service: a rootless, per-user process serving the Docker-compatible API and the native API on a unix socket. Its start-up checks the organisation policy before anything is opened. | | `cmd/kosa8-guestd` | The helper inside each virtual machine. It is the guest's init, built for Linux only. | | `cmd/kosa8-docs` | Dumps the command tree as JSON, so the command reference on this site is generated from the binary and cannot drift from it. | | `internal/cli` | The command tree. Every command supports `--json`, and errors carry a hint about what to do next. | | `pkg/apid` | Serves the service's API: the Docker-compatible tree and the native `/kosa8/v1` tree. Identifies the caller and writes the audit entry for each state-changing request. | | `pkg/apid/gate` | Puts the organisation policy's signed-approval rules in front of every route, and holds the table that classifies each one. | | `pkg/apid/native` | The handlers for `/kosa8/v1`: sandboxes, snapshots, agents, audit, labels. | | `pkg/apid/compat` | The Docker Engine API tree, for the docker CLI, Compose, Testcontainers and Dev Containers. | | `pkg/apid/ops` | Engine operations both trees share, such as pulling an image, with the policy checks that belong to them. | | `pkg/sandbox` | Manages per-session virtual machines and their snapshots: create, run, snapshot, fork, restore. Starts each sandbox's network and broker. | | `pkg/vmm` | The machine layer: booting a microVM, saving its memory and processes, bringing them back. One contract, one backend per operating system. | | `pkg/enginevm`, `pkg/engine` | The shared engine VM, and the protocol the host speaks to the engine inside any VM. | | `pkg/vnet` | Gives each sandbox its own userspace network, and the guard that checks every frame against the sandbox's network rule. | | `pkg/broker` | The per-sandbox HTTP egress broker: the one door a sandbox's traffic leaves through, and where a real API key is kept so it never enters the sandbox. | | `pkg/kit` | Sandbox Kits: the description of an agent's environment, pinned packages, signatures. | | `pkg/mcpd` | kosa8 as an MCP server: its own tools, the gateway for third-party tool servers, pinned tool definitions, held calls. | | `pkg/govmcp` | The signed MCP policy: which servers and tools each scope may use. | | `pkg/schema` | The typed layer under it: checks arguments and results against JSON Schema, and decides calls by argument values. | | `pkg/scorer` | An optional local classifier that decides whether a call a rule marked "ask" is routine enough to proceed. | | `pkg/govern` | The signed organisation policy: its fields, signing, verification and fail-closed loading. | | `pkg/agentid` | Gives an AI agent an identity the service can check: tokens, scopes, revocation. | | `pkg/identity` | Who signed something: a named person and the key that speaks for them. | | `pkg/approve` | The gate between "the assistant decided to do this" and "it happened": classifying actions, and signed approvals. | | `pkg/policyd` | The hash-chained audit log: entries, references, verification. | | `pkg/aiaudit` | Puts what an AI workload did into that log: tool decisions and model exchanges, sent to the service because it is the log's only writer. | | `pkg/auditfwd` | Ships the audit log off the machine and applies retention. | | `pkg/evidence` | Packages what was recorded about a sandbox, a snapshot or a stretch of time into one signed artifact. | | `pkg/model`, `pkg/infer` | The local model runtime with OpenAI- and Anthropic-compatible endpoints, and kosa8's own inference engine. | | `pkg/billing` | Licensing and usage metering. Checked in the clients; the service holds no licence. | | `pkg/registry` | A minimal OCI registry client for pulling images. | | `pkg/crypt`, `pkg/seal`, `pkg/label` | Snapshot encryption, signed snapshot records, and classification labels. | | `pkg/paths` | Every on-disk location, in one place. | Each package starts with a comment that says why it exists and what it deliberately does not do. Read that before the code. ## The path of a request > Diagram: The command line and the MCP server both send requests over a socket to the service. Inside the service, apid identifies the caller and wraps the request for the audit log, apid/gate checks whether the policy wants a signed approval, the native handler does the work through the sandbox manager, and the sandbox manager drives vmm, vnet and broker. The helper inside the virtual machine is reached over a control channel. Every way of using kosa8 enters at the same place, so every way is identified, gated and audited alike. 1. A command (`internal/cli`) or an AI client's tool call (`pkg/mcpd`) becomes an HTTP request on the service's socket. 2. `pkg/apid` works out who is asking: it verifies an agent token if one was sent, and refuses a bad one. For a state-changing request it also wraps the handler, so an audit entry is written with the final status however the request ends. 3. `pkg/apid/gate` wraps every route. If the organisation policy asks for signed approvals of this action, the call needs one, and it is spent here. 4. The handler in `pkg/apid/native` checks the policy fields that apply, adds notes and references to the audit entry, and calls the manager. 5. `pkg/sandbox` owns the sandbox's whole life. 6. It boots the machine through `pkg/vmm`, gives it a network through `pkg/vnet`, and starts a `pkg/broker` when credentials were supplied. 7. Work inside the machine is asked of the guest helper over a control channel that is not a network connection. ## From JavaScript and TypeScript For Node programs there are two npm packages over this API, so you do not have to speak HTTP: **@kosa8/sdk**A typed client for the native API (Node 18+, no runtime dependencies): sandboxes, run and exec, egress and the broker, checkpoint and rewind, fork, explore, snapshots and signed approvals, with typed errors that keep the daemon's words. Start with the [Quickstart](https://docs.kosa8.com/guides/js-quickstart/). **@kosa8/ai-sdk-sandbox**A kosa8 sandbox for the Vercel AI SDK 7 (Node 22+), for agents whose tools read, write and run in a microVM. See [Vercel AI SDK adapter](https://docs.kosa8.com/guides/js-ai-sdk/). [Choosing a path](https://docs.kosa8.com/guides/js-overview/) compares them with MCP and the Docker API. ## From Python, Go, Ruby and Java The same client exists for four more languages, method for method, each with no runtime dependencies: `kosa8` on PyPI ([Python](https://docs.kosa8.com/guides/py-quickstart/)), `github.com/kosa8ai/kosa8-go` ([Go](https://docs.kosa8.com/guides/go-quickstart/)), the `kosa8` gem ([Ruby](https://docs.kosa8.com/guides/rb-quickstart/)) and `com.kosa8:kosa8` on Maven Central ([Java](https://docs.kosa8.com/guides/java-quickstart/)). Checkpoints taken from any of them land in the same `~/.kosa8/checkpoints.json` the CLI uses, so `kosa8 rewind` sees them. [SDKs in five languages](https://docs.kosa8.com/guides/sdks/) compares them, and [Native HTTP API](https://docs.kosa8.com/guides/native-api/) documents the endpoints they all call. ## The service's HTTP API The service listens on a unix socket, `~/.kosa8/run/kosa8.sock`. The socket's file permissions let only your user open it, and that is the whole of its access control: there is no token on it. It serves two trees. **Docker-compatible**At the root and under `/vX.Y/` prefixes: `/containers/json`, `/images/create` and so on. Point any Docker client at it with `DOCKER_HOST=unix://$HOME/.kosa8/run/kosa8.sock`. It reports Docker Engine 28.3.0, API 1.51. Since v0.9.10 it serves bind mounts below the directories named with `kosa8 daemon start --allow-mount DIR[:rw]`, `docker cp` (`/containers/{id}/archive`), `POST /build`, `POST /commit`, and exec with stdin, `-w`, `-e` and `-u`. The Docker socket and `~/.kosa8` are never shared into a container, so Testcontainers needs `TESTCONTAINERS_RYUK_DISABLED=true`. Tested clients: [dockerode and testcontainers-node](https://docs.kosa8.com/guides/js-recipes/#docker), and [more on kosa8.com](https://kosa8.com/docs/integrations/). **Native**Under `/kosa8/v1`. Sandboxes, snapshots, agents, audit. JSON in, JSON out. Errors are `{"error": "…", "remediation": "…"}` with an ordinary HTTP status. ### Headers | Header | Meaning | |---|---| | `X-Kosa8-Actor` | Who you say you are: `human`, `agent` or `tool`. It is recorded in the audit log as a claim. Without it you are recorded as `tool`, which is the honest value for your own program. | | `X-Kosa8-Agent-Token` | An agent's token from `kosa8 agent token create`. The service verifies it, treats the request as that agent's, and names the agent in the audit entry. A bad, expired or revoked token gets `401`, never an anonymous pass. | | `X-Kosa8-Approval` | The id of a recorded signed approval, for a call the organisation policy requires one for. It is spent by the one call it covers. | ### Native routes an integrator needs | Method | Path | Purpose | |---|---|---| | GET | `/kosa8/v1/health` | Is the service up; its version, commit and uptime. | | GET | `/kosa8/v1/sandboxes` | List sandboxes. | | POST | `/kosa8/v1/sandboxes` | Create and boot a sandbox. Body below. | | DELETE | `/kosa8/v1/sandboxes/{id}` | Remove a sandbox. `?wipe=1` overwrites its disks first. | | POST | `/kosa8/v1/sandboxes/{id}/down`, `/up` | Stop a sandbox keeping its disk; start it again. | | POST | `/kosa8/v1/sandboxes/{id}/pull` | Pull an image into the sandbox. Body `{"ref": "alpine"}`. | | POST | `/kosa8/v1/sandboxes/{id}/run` | Run a container to completion. Body `{"image", "args", "env", "workspace"?}`; `workspace: true` mounts `/workspace`. The response is a stream of JSON lines. | | POST | `/kosa8/v1/sandboxes/{id}/exec` | Run a command in a running container. Body `{"container", "args"}`. | | GET | `/kosa8/v1/sandboxes/{id}/egress` | The network rule in force and what it has passed and refused. | | POST | `/kosa8/v1/sandboxes/{id}/snapshot` | Save the running machine. Body `{"id", "encrypt"}`. | | POST | `/kosa8/v1/sandboxes/{id}/fork` | Copy a running sandbox into live copies. Body `{"n", "prefix"}`. | | GET | `/kosa8/v1/snapshots` | List snapshots. | | POST | `/kosa8/v1/snapshots/{id}/restore` | Boot a sandbox from a snapshot. Body `{"name"}`. | | DELETE | `/kosa8/v1/snapshots/{id}` | Delete a snapshot. | | GET | `/kosa8/v1/agents` | List agent identities. | | POST | `/kosa8/v1/agents` | Mint an identity. Body `{"name", "scope", "ttl_seconds"}`; the token is in the response, once. A request from an agent is refused. | | GET | `/kosa8/v1/agents/whoami` | Which identity the presented token is. | | DELETE | `/kosa8/v1/agents/{id}` | Revoke an identity at once. Never held for approval. | | GET | `/kosa8/v1/audit` | Recent audit entries. `?limit=N`, with `0` for all. | | GET | `/kosa8/v1/audit/verify` | Verify the audit chain. | | POST | `/kosa8/v1/ai/records` | How the MCP gateway and model gateway put their records into the audit log. | | POST | `/kosa8/v1/pull` | Pull an image into the shared engine. Body `{"ref"}`. | | POST | `/kosa8/v1/run` | Run a container on the shared engine. Body `{"image", "args"}`. Refused when the policy sets `require_sandbox`. | | GET | `/kosa8/v1/containers`, `/images` | List containers and images on the shared engine. | The full list is the table in `pkg/apid/gate/routes.go`, which the service will not start without. ### Worked example: a sandbox behind a broker This creates a sandbox whose only way out is its egress broker. The real API key goes in the request and stays with the service; the response gives you a placeholder to hand to the sandbox instead. ``` $ curl --unix-socket ~/.kosa8/run/kosa8.sock \ -X POST http://localhost/kosa8/v1/sandboxes \ -H 'Content-Type: application/json' \ -d @- < api.anthropic.com"] }, … }, "boot_ms": 653, "broker": { "url": "http://192.168.127.254:57504", "placeholders": { "anthropic": "kosa8-placeholder-1803c857986afe6bc9f59600e4d6f6f8" } } } ``` | Field | Meaning | |---|---| | `egress` | `deny`, or `allow:host,host`. With a broker it may be left empty, which means `deny`; `any` is refused, because beside an open network the broker would be optional. | | `mounts[]` | `host_path`, `guest_path`, `read_only`. | | `broker.allow[]` | Hosts the sandbox may reach through the broker. `host` is an exact name or `*.domain`; optional `ports` (default 443 and 80) and `methods`. | | `broker.routes[]` | Upstreams the sandbox has a credential for: `name`, `upstream`, `header`, optional `scheme`, `methods`, `paths`, and `secret`, the real value. | | top-level `broker.url` | The broker as the sandbox sees it. A route's base address is this plus `/` and the route's name. | | top-level `broker.placeholders` | Route name to the stand-in the sandbox should present. Returned here and nowhere else. | Give the sandbox the stand-ins as environment variables when you run something in it. The proxy variables send everything else through the broker too: ``` $ curl --unix-socket ~/.kosa8/run/kosa8.sock \ -X POST http://localhost/kosa8/v1/sandboxes/review-1/pull -d '{"ref": "node:22-alpine"}' $ curl --unix-socket ~/.kosa8/run/kosa8.sock -N \ -X POST http://localhost/kosa8/v1/sandboxes/review-1/run -d '{ "image": "node:22-alpine", "args": ["sh", "-lc", "env | grep -c ANTHROPIC"], "env": [ "ANTHROPIC_BASE_URL=http://192.168.127.254:57504/anthropic", "ANTHROPIC_API_KEY=kosa8-placeholder-1803c857986afe6bc9f59600e4d6f6f8", "HTTPS_PROXY=http://192.168.127.254:57504", "HTTP_PROXY=http://192.168.127.254:57504", "NO_PROXY=192.168.127.254" ]}' $ curl --unix-socket ~/.kosa8/run/kosa8.sock \ -X DELETE http://localhost/kosa8/v1/sandboxes/review-1 ``` `run` answers with one JSON object per line: output frames with `stream` and base64 `data`, then a last frame with `done` and the exit status. **Where the secret goes** The secret travels once, over the socket only your user can open. The service keeps it in memory for as long as the sandbox runs and writes it nowhere: not to the sandbox's record, not to a snapshot, not to the audit log, which records route names and upstreams only. The consequence is that a brokered sandbox that is stopped and started, restored or forked comes back with its network closed and no broker. Create a fresh one each time, as `kosa8 agent run` does. ## Driving the CLI instead You do not have to speak HTTP. Every command takes `--json` and prints one JSON document with a stable shape in place of the table: ``` $ kosa8 sandbox ls --json $ kosa8 audit ls --json -n 0 $ kosa8 agent kits --json ``` A failing command exits non-zero and says what to do next. `--approval ID` is the other global flag; it sends the `X-Kosa8-Approval` header for you. ## Writing a new MCP tool kosa8's own tools are registered in `registerTools` in `pkg/mcpd/tools.go`. Each is a thin, typed wrapper over the same service API the CLI uses, so an agent and a person see the same behaviour. A new tool is four things, and the build tells you if you forget the two that matter most. ### An input struct ``` type sandboxRunIn struct { Sandbox string `json:"sandbox" jsonschema:"sandbox name"` Image string `json:"image" jsonschema:"image to run inside the sandbox"` Command []string `json:"command,omitempty" jsonschema:"command and arguments"` Approval string `json:"approval,omitempty" jsonschema:"id of a recorded signed approval …"` } ``` The `jsonschema` tags are what the model reads to learn each argument. Tools that change state take an `approval` argument and begin with `s := s.with(in.Approval)`, so a signed approval can be presented through the tool. A new input type also needs a `case` in the type switch inside `add`. ### A call to `add` ``` add("sandbox_run", "Run a container inside a sandbox's private engine, isolated from the host.", true, func(ctx context.Context, _ *mcp.CallToolRequest, in sandboxRunIn) (*mcp.CallToolResult, any, error) { s := s.with(in.Approval) out, exit, err := s.postStream("/kosa8/v1/sandboxes/"+url.PathEscape(in.Sandbox)+"/run", map[string]any{"image": in.Image, "args": in.Command}) … return text(out), nil, nil }) ``` The third argument says whether the tool changes state, which becomes the hint MCP clients use to ask before calling it. `add` wraps every handler so that the licence check and the MCP policy run before it. ### A price, in `toolFeature` mandatory `pkg/mcpd/gate.go` lists what each tool costs: an empty string for free, or the feature it belongs to. A tool with no entry would default to free, so the server refuses to start instead: ``` panic: mcpd: tool NAME has no entry in toolFeature ``` ### A classification for any new route mandatory If the tool needs a new route on the service, register it in `pkg/apid/native` and add the exact same pattern to `Routes` in `pkg/apid/gate/routes.go`. The entry says what kind of action a call performs, for the signed-approval rules, or why the route never needs approval. A route nobody classified would default to ungated, so the service refuses to start: ``` panic: gate: route "PATTERN" has no entry in gate.Routes — say which approval kind it performs, or why it needs none ``` Two tests hold the tables to reality: `TestEveryToolIsPriced` in `pkg/mcpd` and `TestEveryMutatingRouteIsClassified` in `pkg/apid`. ## The rules the code follows **Fail closed**A policy that is present and does not verify refuses; it never falls back to "no policy". The pattern `if present && err != nil { refuse }` appears at every enforcement point. The same holds for values a rule cannot evaluate: an argument of the wrong type counts against the caller. **One writer for the audit chain**Only the service appends to the log. Other processes that see auditable things, the MCP gateway and the model gateway, send their records to it (`pkg/aiaudit`). Two writers would mean two ideas of what the last hash is. **Signed structs only gain optional fields**A policy is signed over its JSON. Every field added to `govern.Profile` or the MCP policy after the first release is `omitempty`, so a policy that does not use it produces the bytes it always did and its old signature still verifies. **Unknown is escalating**An action kind the classifier in `pkg/approve` does not recognise is treated as escalating, the most cautious class. kosa8 cannot bound the damage of something it does not know. **Defaults are refused, not assumed**An unpriced tool and an unclassified route stop the program at start-up. Shipping the quiet default is how a control ends up enforced from one client only. **Secrets stay in memory**Brokered credentials and agent tokens are never written as themselves: the first live in the service's memory, the second are stored as digests. ## The test layers | Layer | How to run it | What it needs | |---|---|---| | Unit tests, beside each package | `make test`, `make test-race` | Go. | | Frame-level network tests in `pkg/vnet` | `go test ./pkg/vnet` | Go. They drive real frames through the network stack: `TestBrokerPinholeReachesTheBrokerAndNothingElse` proves the hole to the broker is one port wide. | | Sandbox lifecycle in a real VM | `make test-integration`, which runs `KOSA8_INTEGRATION=1 sh test/integration/sandbox-lifecycle.sh` | Guest assets and a signed `kosa8d`. It skips itself, with the reason, where the host cannot run it. | | Egress broker in a real VM | `KOSA8_INTEGRATION=1 sh test/integration/egress-broker.sh` | A running `kosa8d` and the network; it calls `httpbin.org` as the stand-in upstream. | | Docker CLI compatibility | `sh test/compat/docker-cli.sh` (`make test-compat`) | The `docker` CLI and a running `kosa8d`. | | MCP protocol | `python3 test/compat/mcp.py` (`make test-mcp`) | A running `kosa8d`. | | MCP gateway | `python3 test/compat/mcp-gateway.py` | A running `kosa8d`. It registers its own throwaway server and removes it. | | Governed MCP gateway | `HOME=/scratch KOSA8_COMPAT_ISOLATED=1 python3 test/compat/mcp-governance.py` | A scratch `HOME` with guest assets under `.kosa8/guest` and a `kosa8d` started there. | **Never run the governance suite against your own state** `mcp-governance.py` pins a signing key and installs signed policies. On a state directory anyone uses, that would govern their machine with a test policy. It refuses to run unless `KOSA8_COMPAT_ISOLATED=1` is set and no key is pinned yet. ### Fuzz targets ``` $ go test ./pkg/schema -fuzz FuzzEvaluate # argument rules against arbitrary arguments $ go test ./pkg/scorer -fuzz FuzzHeuristic # the deterministic veto in front of the scorer ``` Both take input an agent or a third-party server controls, which is why they are the ones fuzzed. ## Further reading in the repository The design notes in `docs/design/` say why each subsystem is the way it is, and what it does not do. They are in the source tree, not on this site. | File | Subject | |---|---| | `agent-security.md` | The broker, pinned tools, argument rules, agent identity, the scorer and kit supply chain. | | `identity-and-approvals.md` | Personal signing keys and signed approvals. | | `audit-forwarding.md` | Shipping the audit log to a SIEM, and retention. | | `evidence.md` | Signed evidence packages. | | `host-egress.md` | Enforcing network rules on the host. | | `encryption-at-rest.md`, `sealed-snapshots.md`, `edge-sync.md` | Snapshot encryption, sealing and transfer. | | `labels-and-cui.md` | Classification labels. | | `ai-assurance.md` | Approved models and recording what a model was asked. | | `malware-lab.md`, `cyber-range.md`, `gxp-validation.md` | The sector packs built on the same pieces. | ## Where to go next [`JavaScript and TypeScript` The npm packages, MCP and the Docker API from Node, with tested recipes.](https://docs.kosa8.com/guides/js-overview/) [`How kosa8 works` The three programs and the boundary, with the picture.](https://docs.kosa8.com/guides/how-it-works/) [`The audit log` The entry format your integration's requests will produce.](https://docs.kosa8.com/guides/audit/) [`Policy reference` Every policy field and where the code enforces it.](https://docs.kosa8.com/guides/policy-reference/) [`Writing a kit` The file behind `kosa8 agent run`.](https://docs.kosa8.com/guides/kits/) --- Source: https://docs.kosa8.com/guides/developers/ # kosa8 agent Run coding agents in isolated sandboxes An agent runs inside its own microVM: it sees the workspace you name and nothing else on the host, and it can only reach the hosts its kit allows. Those limits are firewall rules and mount scopes, not instructions the agent could ignore. ## Subcommands - [`kosa8 agent init`](https://docs.kosa8.com/reference/agent-init.md): Write a built-in kit to ~/.kosa8/kits so it can be customized - [`kosa8 agent kits`](https://docs.kosa8.com/reference/agent-kits.md): List available sandbox kits - [`kosa8 agent run`](https://docs.kosa8.com/reference/agent-run.md): Start an agent sandbox from a kit - [`kosa8 agent sign`](https://docs.kosa8.com/reference/agent-sign.md): Sign a kit file with the org's policy key - [`kosa8 agent token`](https://docs.kosa8.com/reference/agent-token.md): Give an agent an identity the daemon can check --- Source: https://docs.kosa8.com/reference/agent/ # kosa8 agent init Write a built-in kit to ~/.kosa8/kits so it can be customized ## Usage ``` kosa8 init KIT ``` --- Source: https://docs.kosa8.com/reference/agent-init/ # kosa8 agent kits List available sandbox kits --- Source: https://docs.kosa8.com/reference/agent-kits/ # kosa8 agent run Start an agent sandbox from a kit ## Usage ``` kosa8 run KIT [ARGS...] ``` ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--allow-unpinned` | bool | run a kit whose setup installs software that is not pinned to a version and digest | | | `--dry-run` | bool | show what would run without creating the sandbox | | | `--keep` | bool | keep the sandbox after the agent exits | | | `--name` | string | sandbox name | | | `--no-network` | bool | deny all network access, overriding the kit | | | `--workspace, -w` | string | host directory the agent may see (default: current directory) | | --- Source: https://docs.kosa8.com/reference/agent-run/ # kosa8 agent sign Sign a kit file with the org's policy key ## Usage ``` kosa8 sign PRIVATE_KEY_FILE KIT_FILE ``` Writes KIT_FILE.sig beside the kit. On a machine that has pinned the org's key (`kosa8 policy trust`), only kits signed by it are run; the key is the one `kosa8 policy keygen` made, so one key vouches for policy and kits alike. --- Source: https://docs.kosa8.com/reference/agent-sign/ # kosa8 agent token Give an agent an identity the daemon can check An agent identity is a name, an MCP policy scope and a token. Give the token to the agent's gateway (`KOSA8_AGENT_TOKEN=… kosa8 mcp serve`): every action it takes is then recorded under that identity, and the tools it may call are decided in that identity's scope rather than the policy's default. ## Subcommands - [`kosa8 agent token create`](https://docs.kosa8.com/reference/agent-token-create.md): Mint an identity and print its token, once - [`kosa8 agent token ls`](https://docs.kosa8.com/reference/agent-token-ls.md): List agent identities - [`kosa8 agent token revoke`](https://docs.kosa8.com/reference/agent-token-revoke.md): End an agent identity at once --- Source: https://docs.kosa8.com/reference/agent-token/ # kosa8 agent token create Mint an identity and print its token, once ## Usage ``` kosa8 create NAME ``` ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--owner` | string | person accountable for the agent, "Name " (default: your kosa8 identity) | | | `--scope` | string | MCP policy scope the agent acts in (default: the policy's default_scope) | | | `--ttl` | duration | how long the token works, e.g. 8h (at most 8760h) | `24h0m0s` | --- Source: https://docs.kosa8.com/reference/agent-token-create/ # kosa8 agent token ls List agent identities --- Source: https://docs.kosa8.com/reference/agent-token-ls/ # kosa8 agent token revoke End an agent identity at once ## Usage ``` kosa8 revoke NAME_OR_ID ``` --- Source: https://docs.kosa8.com/reference/agent-token-revoke/ # kosa8 ai Chat with kosa8's embedded assistant (approval-first, live state) An assistant that sees your live container/image state and can run read-only queries freely; anything that changes state asks first. Provider: with ANTHROPIC_API_KEY set it uses the Anthropic API (--provider anthropic); otherwise it uses a local model gateway (`kosa8 model serve NAME`, --provider local). No code content is sent anywhere except the provider you choose. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--message, -m` | string | ask one question and exit | | | `--model` | string | override the model id | | | `--provider` | string | anthropic \| local \| auto (default: auto) | | ## Subcommands - [`kosa8 ai bom`](https://docs.kosa8.com/reference/ai-bom.md): Write a CycloneDX 1.6 AI bill of materials for a sandbox, session or model - [`kosa8 ai replay`](https://docs.kosa8.com/reference/ai-replay.md): Read back a model gateway session's replay record and check it against the audit log --- Source: https://docs.kosa8.com/reference/ai/ # kosa8 ai bom Write a CycloneDX 1.6 AI bill of materials for a sandbox, session or model ## Usage ``` kosa8 bom (--sandbox S | --session ID | --model M) ``` An AI bill of materials says what an AI workload was made of: the model, by the SHA-256 of its weights, with its source, signature and approval; the inference runtime; the MCP servers its agent was offered, used and refused; and the signed policies in force. --sandbox and --session read what the audit log recorded for that workload (`kosa8 model serve` and `kosa8 mcp serve` with --sandbox/--session); --model describes a stored model as it would be served now. Output is deterministic: the same records give the same bytes, and there is no timestamp unless you pass --timestamp. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--model` | string | a stored model, by local name | | | `--out, -o` | string | write to this file instead of stdout | | | `--sandbox` | string | the sandbox the AI workload ran in | | | `--session` | string | an AI session id (from `kosa8 model serve` or `kosa8 mcp serve`) | | | `--timestamp` | string | RFC 3339 time for metadata.timestamp (default: none, so output is reproducible) | | --- Source: https://docs.kosa8.com/reference/ai-bom/ # kosa8 ai replay Read back a model gateway session's replay record and check it against the audit log ## Usage ``` kosa8 replay SESSION ``` Lists each request the model gateway answered in SESSION, with the audit entry that anchors it, and checks that the two agree. With --content, and when the org policy recorded content, decrypts the text and checks it against the digests in the chain. This replays the record, not the model: re-running a model does not reproduce its output. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--content` | bool | decrypt and print recorded text, and check it against the chain's digests | | --- Source: https://docs.kosa8.com/reference/ai-replay/ # kosa8 approve See what an action would do, and decide what may run unprompted kosa8 proposes, you approve. Every mutating action the assistant takes is shown first — the real command, the sandbox it runs in, the host paths it would reach — and waits for an answer. Actions are classified by blast radius: read-only runs unprompted, work inside an existing sandbox asks once per session, and anything destructive or anything that widens the sandbox boundary asks every time. Those last two cannot be auto-approved, by design: a setting that can switch off every control is not a control. Permission you give the assistant mid-conversation lasts for that conversation only. It is never written to disk, so closing the session is all it takes to revoke it. ## Subcommands - [`kosa8 approve add`](https://docs.kosa8.com/reference/approve-add.md): File approvals sent back by approvers against their request - [`kosa8 approve allow`](https://docs.kosa8.com/reference/approve-allow.md): Let a trusted kind of action run without asking - [`kosa8 approve check`](https://docs.kosa8.com/reference/approve-check.md): Show what an action would do and whether it needs approval - [`kosa8 approve deny`](https://docs.kosa8.com/reference/approve-deny.md): Refuse a kind of action outright, without even prompting - [`kosa8 approve ls`](https://docs.kosa8.com/reference/approve-ls.md): Show what may run without asking - [`kosa8 approve pending`](https://docs.kosa8.com/reference/approve-pending.md): List approval requests on this machine that have not been used - [`kosa8 approve request`](https://docs.kosa8.com/reference/approve-request.md): Ask for signed approvals of an action the org policy covers - [`kosa8 approve revoke`](https://docs.kosa8.com/reference/approve-revoke.md): Go back to being asked about a kind of action - [`kosa8 approve sign`](https://docs.kosa8.com/reference/approve-sign.md): Approve someone else's request, signed with your identity - [`kosa8 approve status`](https://docs.kosa8.com/reference/approve-status.md): Show whether a request has the approvals it needs, and who is still missing --- Source: https://docs.kosa8.com/reference/approve/ # kosa8 approve add File approvals sent back by approvers against their request ## Usage ``` kosa8 add APPROVAL.json... ``` --- Source: https://docs.kosa8.com/reference/approve-add/ # kosa8 approve allow Let a trusted kind of action run without asking ## Usage ``` kosa8 allow KIND ``` Adds a kind (run, exec, logs) or a class (read-only, contained) to the auto-approve list. Destructive and escalating actions are refused here rather than accepted and quietly ignored, so nobody walks away believing their agent will not stop on `rm`. --- Source: https://docs.kosa8.com/reference/approve-allow/ # kosa8 approve check Show what an action would do and whether it needs approval ## Usage ``` kosa8 check KIND [ARG...] ``` Classifies an action without running it. KIND is a kosa8 verb (run, exec, rm, rewind) or an assistant tool name (run_container). Describe the action as it would really happen — including --mount and --egress — because those are what decide the class, not the verb. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--egress` | string | network egress the action would set, e.g. deny or allow:api.example.com | | | `--mount` | stringArray | host directory the action would expose into the sandbox | `[]` | | `--policy-edit` | bool | the action rewrites governance or approval policy | | | `--privilege` | stringArray | a power beyond an ordinary container's, e.g. privileged or cap-add=SYS_ADMIN | `[]` | | `--publish` | stringArray | a container port the action would publish on the host, e.g. 8080:80/tcp | `[]` | | `--sandbox` | string | the existing sandbox the action runs inside | | | `--target` | string | what it operates on (default: the first argument) | | ## Example ``` kosa8 approve check run alpine sh -c 'apk add curl' kosa8 approve check --sandbox dev --mount /Users/me/src run node kosa8 approve check rm dev ``` --- Source: https://docs.kosa8.com/reference/approve-check/ # kosa8 approve deny Refuse a kind of action outright, without even prompting ## Usage ``` kosa8 deny KIND ``` Some things should not be reachable by saying yes at the wrong moment. A denied kind is refused before any prompt, and no auto-approve entry or session grant can override it. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--undo` | bool | remove the kind from the deny list instead | | --- Source: https://docs.kosa8.com/reference/approve-deny/ # kosa8 approve ls Show what may run without asking --- Source: https://docs.kosa8.com/reference/approve-ls/ # kosa8 approve pending List approval requests on this machine that have not been used --- Source: https://docs.kosa8.com/reference/approve-pending/ # kosa8 approve request Ask for signed approvals of an action the org policy covers ## Usage ``` kosa8 request KIND [ARG...] ``` Opens a request, signed with your identity, for an action the org policy says needs signed approvals. Describe the action exactly as with `kosa8 approve check`. Hand the request file it prints to approvers; each runs `kosa8 approve sign` on it. When the daemon refuses a call for want of an approval, it prints this command with the action filled in exactly. Once the request is recorded (`kosa8 approve status ID --record`), repeat the call with --approval ID; the approval covers that one call, once. Flags go before KIND: every word after it is the action. A --reason at the very end is still read as the reason; an action that itself ends in --reason goes after --. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--egress` | string | network egress the action would set, e.g. deny or allow:api.example.com | | | `--identity` | string | your identity directory (default ~/.kosa8/identity) | | | `--mount` | stringArray | host directory the action would expose into the sandbox | `[]` | | `--policy-edit` | bool | the action rewrites governance or approval policy | | | `--privilege` | stringArray | a power beyond an ordinary container's, e.g. privileged or cap-add=SYS_ADMIN | `[]` | | `--publish` | stringArray | a container port the action would publish on the host, e.g. 8080:80/tcp | `[]` | | `--reason` | string | why you need this, shown to approvers | | | `--sandbox` | string | the existing sandbox the action runs inside | | | `--target` | string | what it operates on (default: the first argument) | | | `--ttl` | duration | how long approvals may be collected before the request expires | `1h0m0s` | ## Example ``` kosa8 approve request --reason "ticket OPS-112" --egress allow:api.example.com run dev ``` --- Source: https://docs.kosa8.com/reference/approve-request/ # kosa8 approve revoke Go back to being asked about a kind of action ## Usage ``` kosa8 revoke KIND ``` --- Source: https://docs.kosa8.com/reference/approve-revoke/ # kosa8 approve sign Approve someone else's request, signed with your identity ## Usage ``` kosa8 sign REQUEST ``` Shows the request — the exact action, where it runs, who asked and why — and signs an approval of it with your identity. REQUEST is a request file or, for a request on this machine, its id. The approval names this request by the digest of its signed bytes, so it cannot be moved onto a different action. You cannot approve a request you made yourself. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--identity` | string | your identity directory (default ~/.kosa8/identity) | | | `--out, -o` | string | also write the approval to this file | | | `--reason` | string | why you approve (required; signed and recorded) | | | `--yes, -y` | bool | sign without the confirmation prompt | | ## Example ``` kosa8 approve sign request.json --reason "reviewed, matches OPS-112" ``` --- Source: https://docs.kosa8.com/reference/approve-sign/ # kosa8 approve status Show whether a request has the approvals it needs, and who is still missing ## Usage ``` kosa8 status REQUEST ``` Checks every approval filed for the request against the org policy: signatures, that each signer is a listed approver with the right role, and that nobody approved their own request. Exits non-zero until the request is fully approved, so a script can wait on it. --record submits a satisfied request to the daemon, which verifies it again and writes it to the audit log. A request can be recorded once. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--record` | bool | record a satisfied request in the audit log (once) | | --- Source: https://docs.kosa8.com/reference/approve-status/ # kosa8 attach Attach to a running container's output ## Usage ``` kosa8 attach CONTAINER ``` --- Source: https://docs.kosa8.com/reference/attach/ # kosa8 audit Inspect what was done, by whom Every state-changing action is appended to a hash-chained log at ~/.kosa8/audit.jsonl, tagged with whether a human or an agent did it. `kosa8 audit verify` proves the log has not been edited. `kosa8 audit forward` ships it to a SIEM, and `kosa8 audit prune` applies local retention without breaking the chain; kosa8d does both on its own once ~/.kosa8/audit-config.json configures them. ## Subcommands - [`kosa8 audit export`](https://docs.kosa8.com/reference/audit-export.md): Export the full audit log for a SIEM (--format jsonl|cef) - [`kosa8 audit forward`](https://docs.kosa8.com/reference/audit-forward.md): Ship audit entries to the configured SIEM sinks (syslog, Splunk HEC, HTTPS) - [`kosa8 audit key`](https://docs.kosa8.com/reference/audit-key.md): Print the public keys that sign the audit log - [`kosa8 audit ls`](https://docs.kosa8.com/reference/audit-ls.md): Show recent audited actions - [`kosa8 audit prune`](https://docs.kosa8.com/reference/audit-prune.md): Apply the retention policy now: seal old entries and remove them from the live log - [`kosa8 audit status`](https://docs.kosa8.com/reference/audit-status.md): Show where audit entries are forwarded, how far each sink has got, and retention - [`kosa8 audit verify`](https://docs.kosa8.com/reference/audit-verify.md): Check that the audit log has not been altered --- Source: https://docs.kosa8.com/reference/audit/ # kosa8 audit export Export the full audit log for a SIEM (--format jsonl|cef) Writes every audit entry, as newline-delimited JSON or ArcSight CEF. When anything on this host is labelled — or the log records that something was — the export begins with one marking record: the highest label involved, its banner, and the CUI designation indicator from the org policy ({"kosa8_marking": ...} in jsonl, an export.marking event in CEF). An export of an unlabelled host is unchanged. --label CUI//SP-EXPT exports only the entries whose structured refs name that classification — as what the entry is about, or as the label a relabel moved it from — and the marking is then that label's. Entries written before audit entries carried refs are not selected; they name no label by value. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--format` | string | output format: jsonl or cef | `jsonl` | | `--label` | string | export only entries whose refs name this classification (e.g. CUI//SP-EXPT) | | --- Source: https://docs.kosa8.com/reference/audit-export/ # kosa8 audit forward Ship audit entries to the configured SIEM sinks (syslog, Splunk HEC, HTTPS) Sends every audit entry a sink has not yet received, in order, with its sequence number and chain hashes, so the receiving side can prove nothing was dropped or edited. Each sink keeps a durable cursor: an interrupted run resumes where it stopped, and a resent record is recognisable by its sequence number. kosa8d forwards on its own while it runs; this command is for testing a sink, catching one up now, or running a forwarder yourself (--follow). Sinks are configured in ~/.kosa8/audit-config.json. Credentials are named by file or environment variable, never written there: { "sinks": [ {"name": "siem", "type": "syslog", "address": "siem.example.gov:6514", "ca_file": "/etc/pki/siem-ca.pem", "cert_file": "/etc/pki/kosa8.pem", "key_file": "/etc/pki/kosa8.key"}, {"name": "splunk", "type": "splunk-hec", "url": "https://splunk.example.com:8088", "token_file": "/etc/kosa8/hec.token"} ], "retention": {"max_age_days": 400, "min_entries": 10000} } With KOSA8_OFFLINE=1 this refuses at once instead of waiting on the network. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--follow, -f` | bool | keep forwarding new entries until interrupted | | | `--sink` | string | forward to this sink only | | ## Example ``` kosa8 audit forward kosa8 audit forward --sink splunk kosa8 audit forward --follow ``` --- Source: https://docs.kosa8.com/reference/audit-forward/ # kosa8 audit key Print the public keys that sign the audit log Every audit entry is signed (ed25519 by default; ecdsa-p256-sha256 for a hardware key) as well as HMAC'd, so anyone with the public key can check an entry without audit.key. This lists every key that has signed this machine's log, oldest first. Give the kid to whoever will verify its records, over a channel other than the records themselves. --- Source: https://docs.kosa8.com/reference/audit-key/ # kosa8 audit ls Show recent audited actions ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--actor` | string | filter by actor: human, agent, tool, guest or policy | | | `--limit, -n` | int | number of entries to show | `25` | --- Source: https://docs.kosa8.com/reference/audit-ls/ # kosa8 audit prune Apply the retention policy now: seal old entries and remove them from the live log Removes entries older than retention.max_age_days from the front of the live log, archiving them into sealed segments (or, with "mode": "delete", keeping only the seal). The chain still verifies afterwards: it resumes from an authenticated checkpoint of the last entry removed. Never removes an entry a configured sink has not received, fewer than min_entries, anything younger than the org policy's minimum, or usage not yet reported for billing. The daemon applies retention hourly on its own. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--dry-run` | bool | report what would be pruned without changing anything | | --- Source: https://docs.kosa8.com/reference/audit-prune/ # kosa8 audit status Show where audit entries are forwarded, how far each sink has got, and retention --- Source: https://docs.kosa8.com/reference/audit-status/ # kosa8 audit verify Check that the audit log has not been altered Walks the hash chain and confirms every entry is authentic and none is missing. After retention has pruned, the chain resumes from a sealed checkpoint of the last entry removed; --archive also verifies the sealed segments those entries were moved into, as one chain with the live log. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--archive` | bool | also verify the sealed segments retention archived | | --- Source: https://docs.kosa8.com/reference/audit-verify/ # kosa8 build Build an image from a Dockerfile ## Usage ``` kosa8 build [PATH] ``` Builds inside the engine VM, so the build cannot reach your filesystem outside the context directory you give it. The context is sent to the VM first, so a directory full of large files it does not need makes every build slower — a .dockerignore is worth writing. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--build-arg` | stringArray | build-time variable KEY=VALUE | `[]` | | `--file, -f` | string | Dockerfile name within the context | `Dockerfile` | | `--tag, -t` | string | name for the built image | | --- Source: https://docs.kosa8.com/reference/build/ # kosa8 builder Manage the BuildKit builder ## Subcommands - [`kosa8 builder prune`](https://docs.kosa8.com/reference/builder-prune.md): Clear the build cache --- Source: https://docs.kosa8.com/reference/builder/ # kosa8 builder prune Clear the build cache --- Source: https://docs.kosa8.com/reference/builder-prune/ # kosa8 checkpoint Save a restore point you can rewind to ## Usage ``` kosa8 checkpoint SANDBOX ``` Captures the sandbox exactly as it is — memory, running processes and disk — and remembers it as a named restore point. Take one before letting an agent do something you might regret. If it goes wrong, `kosa8 rewind SANDBOX` puts the machine back, still mid-task. Only the last 10 are kept per sandbox: each one costs a disk clone, so an agent checkpointing on every step would otherwise fill the disk. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--label` | string | why you took it, e.g. "before migration" | | ## Subcommands - [`kosa8 checkpoint ls`](https://docs.kosa8.com/reference/checkpoint-ls.md): List the restore points for a sandbox --- Source: https://docs.kosa8.com/reference/checkpoint/ # kosa8 checkpoint ls List the restore points for a sandbox ## Usage ``` kosa8 ls SANDBOX ``` --- Source: https://docs.kosa8.com/reference/checkpoint-ls/ # kosa8 cloud Run sandboxes on kosa8 Cloud (api.kosa8.com) kosa8 Cloud runs the same sealed sandboxes on kosa8's hosts, or on your own, billed by the second. Sign in once (kosa8 login, or an API key), then make, run in, sleep and remove cloud sandboxes from here; the dashboard at https://app.kosa8.com shows the same ones. kosa8 login # sign in in your browser kosa8 cloud login --key k8c_live_… # or with an API key (CI) kosa8 cloud sandbox create # prints its ID and a dashboard link kosa8 cloud sandbox exec ID -- uname -a kosa8 cloud usage # this month's bill so far kosa8 cloud mcp # connect Claude Code, Cursor or Codex The key is kept in ~/.kosa8/cloud.json, readable only by you, and shared with the kosa8 desktop app. KOSA8_API_KEY and KOSA8_API_URL, which the SDKs read, take precedence when set. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--api` | string | kosa8 Cloud API address (default: KOSA8_API_URL, then the signed-in address, then https://api.kosa8.com) | | ## Subcommands - [`kosa8 cloud login`](https://docs.kosa8.com/reference/cloud-login.md): Sign in to kosa8 Cloud: in your browser, or with an API key - [`kosa8 cloud logout`](https://docs.kosa8.com/reference/cloud-logout.md): Forget the kosa8 Cloud key on this machine - [`kosa8 cloud mcp`](https://docs.kosa8.com/reference/cloud-mcp.md): Connect Claude Code, Cursor or Codex to kosa8 Cloud's MCP server - [`kosa8 cloud open`](https://docs.kosa8.com/reference/cloud-open.md): Open the kosa8 Cloud dashboard, at a sandbox if given - [`kosa8 cloud refer`](https://docs.kosa8.com/reference/cloud-refer.md): Show your Refer and earn link, earnings and invitations - [`kosa8 cloud sandbox`](https://docs.kosa8.com/reference/cloud-sandbox.md): Make, list, run in, sleep, wake and remove cloud sandboxes - [`kosa8 cloud usage`](https://docs.kosa8.com/reference/cloud-usage.md): Show this month's kosa8 Cloud usage and estimated bill - [`kosa8 cloud whoami`](https://docs.kosa8.com/reference/cloud-whoami.md): Show the kosa8 Cloud organization and key in use --- Source: https://docs.kosa8.com/reference/cloud/ # kosa8 cloud login Sign in to kosa8 Cloud: in your browser, or with an API key At a terminal with no key given, offers to sign in in your browser (the same as `kosa8 login`); otherwise checks the key with kosa8 Cloud (GET /v1/me). Either way the key is kept in ~/.kosa8/cloud.json, readable only by you. The kosa8 desktop app uses the same file, so it is signed in too. The key comes from --key, else KOSA8_API_KEY, else a hidden prompt (or one line on stdin, for a script). --key leaves the key in your shell history; prefer the prompt. Make a key in the dashboard: https://app.kosa8.com/app#keys ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--key` | string | the API key (k8c_live_…); prefer the prompt, this stays in shell history | | | `--no-browser` | bool | for the browser sign-in: print the link instead of opening it | | ## Example ``` kosa8 cloud login printf %s "$KEY" | kosa8 cloud login KOSA8_API_KEY=k8c_live_… kosa8 cloud login ``` --- Source: https://docs.kosa8.com/reference/cloud-login/ # kosa8 cloud logout Forget the kosa8 Cloud key on this machine Removes ~/.kosa8/cloud.json, which also unlinks the kosa8 desktop app. The key itself still works until it is revoked in the dashboard (API keys). --- Source: https://docs.kosa8.com/reference/cloud-logout/ # kosa8 cloud mcp Connect Claude Code, Cursor or Codex to kosa8 Cloud's MCP server ## Usage ``` kosa8 mcp [CLIENT...] ``` kosa8 Cloud has a remote MCP server at https://api.kosa8.com/mcp. An AI agent connected to it can create cloud sandboxes, run commands in them, put them to sleep and delete them, and read this month's usage, with your Cloud API key. With no flags this prints the config for Claude Code, Cursor, Codex and any other MCP client, reading the key from $KOSA8_API_KEY (--show-key puts the signed-in key in instead). Nothing is written. --install writes it into the clients named (claude-code, cursor, codex), or every one found on this machine: Claude Code with `claude mcp add --scope user`, Cursor in ~/.cursor/mcp.json (mode 0600, the old file kept as .bak), Codex in ~/.codex/config.toml (Codex reads the key from KOSA8_API_KEY, so it is not written there). --uninstall takes it out again. The server is named kosa8-cloud, beside the local kosa8 server from `kosa8 mcp install`. kosa8 cloud mcp # print every config kosa8 cloud mcp cursor --show-key # Cursor's, with the key in it kosa8 cloud mcp --install claude-code ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--install` | bool | write the config into the named clients (or every one found) | | | `--show-key` | bool | put the signed-in key in the printed config instead of $KOSA8_API_KEY | | | `--uninstall` | bool | remove kosa8-cloud from the named clients (or every one found) | | --- Source: https://docs.kosa8.com/reference/cloud-mcp/ # kosa8 cloud open Open the kosa8 Cloud dashboard, at a sandbox if given ## Usage ``` kosa8 open [ID] ``` Opens the dashboard's Sandboxes page in a browser and prints the address. With an ID the link carries it (…/app#sandboxes/ID); the dashboard opens it at the Sandboxes list. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--no-open` | bool | print the address without opening a browser | | --- Source: https://docs.kosa8.com/reference/cloud-open/ # kosa8 cloud refer Show your Refer and earn link, earnings and invitations Give $30, get $30: share your link; when a team signs up with it and makes its first payment, you both get kosa8 Cloud credit. This shows the link, what you have earned this year against the yearly cap, and each invitation's state. The affiliate program (20% for 12 months) is in the dashboard: Refer and earn. --- Source: https://docs.kosa8.com/reference/cloud-refer/ # kosa8 cloud sandbox Make, list, run in, sleep, wake and remove cloud sandboxes Also known as: `sandboxes`, `sbx` ## Subcommands - [`kosa8 cloud sandbox create`](https://docs.kosa8.com/reference/cloud-sandbox-create.md): Make a cloud sandbox and print its ID and dashboard link - [`kosa8 cloud sandbox exec`](https://docs.kosa8.com/reference/cloud-sandbox-exec.md): Run a command in a cloud sandbox and print its output - [`kosa8 cloud sandbox get`](https://docs.kosa8.com/reference/cloud-sandbox-get.md): Show one cloud sandbox - [`kosa8 cloud sandbox ls`](https://docs.kosa8.com/reference/cloud-sandbox-ls.md): List the organization's cloud sandboxes - [`kosa8 cloud sandbox rm`](https://docs.kosa8.com/reference/cloud-sandbox-rm.md): Delete cloud sandboxes; their disks are wiped - [`kosa8 cloud sandbox sleep`](https://docs.kosa8.com/reference/cloud-sandbox-sleep.md): Snapshot a sandbox and free its memory; it pays only for disk until it wakes - [`kosa8 cloud sandbox wake`](https://docs.kosa8.com/reference/cloud-sandbox-wake.md): Restore a sleeping sandbox now, rather than on its next exec --- Source: https://docs.kosa8.com/reference/cloud-sandbox/ # kosa8 cloud sandbox create Make a cloud sandbox and print its ID and dashboard link Starts a sandbox on kosa8 Cloud from a template (the organization's default, else base). --sleep-after puts it to sleep after that long idle, which frees its memory and stops the hourly charge; 0 means never. Left out, the organization's setting applies. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--region` | string | region id, e.g. eu-hel1 (default: any) | | | `--sleep-after` | duration | sleep after this long idle, e.g. 15m; 0 never (default: the organization's setting) | `0s` | | `--template` | string | template key, e.g. base (default: the organization's default) | | ## Example ``` kosa8 cloud sandbox create kosa8 cloud sandbox create --template python --region eu-hel1 --sleep-after 15m id=$(kosa8 cloud sandbox create --json | jq -r .sandbox.id) ``` --- Source: https://docs.kosa8.com/reference/cloud-sandbox-create/ # kosa8 cloud sandbox exec Run a command in a cloud sandbox and print its output ## Usage ``` kosa8 exec ID -- COMMAND [ARG...] ``` Runs the command to completion and prints what it wrote; kosa8 exits with the command's exit code. A sleeping sandbox wakes for it. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--env, -e` | stringArray | set an environment variable, NAME=value (repeatable) | `[]` | | `--image` | string | image to run the command in (default: the template's own) | | ## Example ``` kosa8 cloud sandbox exec sbx_abc -- python3 -c 'print(1+1)' kosa8 cloud sandbox exec sbx_abc -e DEBUG=1 -- ./run.sh ``` --- Source: https://docs.kosa8.com/reference/cloud-sandbox-exec/ # kosa8 cloud sandbox get Show one cloud sandbox ## Usage ``` kosa8 get ID ``` Also known as: `inspect` --- Source: https://docs.kosa8.com/reference/cloud-sandbox-get/ # kosa8 cloud sandbox ls List the organization's cloud sandboxes Also known as: `list` ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--all, -a` | bool | include deleted sandboxes | | | `--quiet, -q` | bool | print only IDs | | --- Source: https://docs.kosa8.com/reference/cloud-sandbox-ls/ # kosa8 cloud sandbox rm Delete cloud sandboxes; their disks are wiped ## Usage ``` kosa8 rm ID [ID...] ``` Also known as: `delete` --- Source: https://docs.kosa8.com/reference/cloud-sandbox-rm/ # kosa8 cloud sandbox sleep Snapshot a sandbox and free its memory; it pays only for disk until it wakes ## Usage ``` kosa8 sleep ID [ID...] ``` --- Source: https://docs.kosa8.com/reference/cloud-sandbox-sleep/ # kosa8 cloud sandbox wake Restore a sleeping sandbox now, rather than on its next exec ## Usage ``` kosa8 wake ID [ID...] ``` --- Source: https://docs.kosa8.com/reference/cloud-sandbox-wake/ # kosa8 cloud usage Show this month's kosa8 Cloud usage and estimated bill The month so far, as the dashboard's Usage page shows it: compute, sleeping storage, functions, credits applied and the net estimate. For local usage (from the audit log) see kosa8 usage. --- Source: https://docs.kosa8.com/reference/cloud-usage/ # kosa8 cloud whoami Show the kosa8 Cloud organization and key in use --- Source: https://docs.kosa8.com/reference/cloud-whoami/ # kosa8 commit Create an image from a container's filesystem ## Usage ``` kosa8 commit CONTAINER IMAGE ``` --- Source: https://docs.kosa8.com/reference/commit/ # kosa8 cp Copy files between the host and a container (CONTAINER:PATH) ## Usage ``` kosa8 cp SRC DST ``` --- Source: https://docs.kosa8.com/reference/cp/ # kosa8 create Create a container without starting it ## Usage ``` kosa8 create IMAGE [COMMAND] [ARG...] ``` ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--name` | string | assign a name to the container | | --- Source: https://docs.kosa8.com/reference/create/ # kosa8 daemon Manage the kosa8d daemon ## Subcommands - [`kosa8 daemon start`](https://docs.kosa8.com/reference/daemon-start.md): Start kosa8d in the background - [`kosa8 daemon status`](https://docs.kosa8.com/reference/daemon-status.md): Show daemon status - [`kosa8 daemon stop`](https://docs.kosa8.com/reference/daemon-stop.md): Stop the daemon --- Source: https://docs.kosa8.com/reference/daemon/ # kosa8 daemon start Start kosa8d in the background Starts the daemon. With --listen it serves the API over TCP behind a generated token (~/.kosa8/worker-token) so other devices can drive this machine — pair them with `kosa8 worker pair` — and this CLI keeps working, over loopback. The unix socket is served as well, unchanged, for this machine's Docker clients and SDKs. Docker clients may bind-mount host paths (docker run -v) only below a directory named with --allow-mount DIR (read-only) or DIR:rw. The list replaces the saved one (~/.kosa8/allow-mounts.json) and is what the engine VM can see of the host; nothing else is shared. kosa8's own state directory and API sockets never are. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--allow-mount` | stringArray | host directory Docker clients may bind-mount from, DIR (read-only) or DIR:rw (repeatable; replaces the saved list) | `[]` | | `--listen` | string | serve the API on this TCP address (e.g. 127.0.0.1:7440) for other devices | | | `--no-allow-mounts` | bool | clear the saved list: Docker clients may bind-mount nothing | | --- Source: https://docs.kosa8.com/reference/daemon-start/ # kosa8 daemon status Show daemon status --- Source: https://docs.kosa8.com/reference/daemon-status/ # kosa8 daemon stop Stop the daemon --- Source: https://docs.kosa8.com/reference/daemon-stop/ # kosa8 desk Open the local dashboard in a browser Serves a dashboard on 127.0.0.1 showing sandboxes, containers, images, snapshots and checkpoints as they change, with the fork/checkpoint tree that no other container UI can draw. The URL carries a session token generated for this run. Anything without it is refused, because a page can create and destroy containers and any website you have open can talk to 127.0.0.1. Stopping the command revokes the token. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--addr` | string | full listen address, e.g. 127.0.0.1:7444 (loopback only; overrides --port) | | | `--no-open` | bool | print the URL instead of opening a browser | | | `--port` | int | port to serve on (always bound to 127.0.0.1) | `7444` | --- Source: https://docs.kosa8.com/reference/desk/ # kosa8 diff Show filesystem changes made to a container ## Usage ``` kosa8 diff CONTAINER ``` --- Source: https://docs.kosa8.com/reference/diff/ # kosa8 doctor Check that this machine can run kosa8, and repair what is safe to repair Checks the things that actually stop kosa8 working, in the order they bite: whether this machine can run VMs at all, whether the guest kernel and rootfs are installed, whether the daemon binary carries what the platform requires of it, and whether anything is in a state that will fail later for reasons the error message will not explain. The checks follow the platform: macOS asks about Virtualization.framework, Linux about KVM and Cloud Hypervisor, and a machine with no local engine about the remote worker it is configured to drive. Run this first when something fails in a way you cannot account for. Each failed check says what was observed, why it matters, and what fixes it. With --fix, kosa8 repairs the failures that are safe to repair unattended and tells you what it did. It will not repair anything destructive, whatever you pass it: removing a machine or stopping a daemon might be throwing away work kosa8 cannot see, so those are printed for you to run. --dry-run shows what --fix would do without doing it. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--compliance` | bool | check this machine against the hardening baseline instead (docs/HARDENING.md) | | | `--dry-run` | bool | show what --fix would do, and change nothing | | | `--fix` | bool | repair what is safe to repair without asking | | --- Source: https://docs.kosa8.com/reference/doctor/ # kosa8 events Stream engine events --- Source: https://docs.kosa8.com/reference/events/ # kosa8 evidence Export and verify signed evidence packages An evidence package is what kosa8 recorded about a sandbox, a snapshot, an approval or a stretch of time, cut from the audit log so that it still verifies: the audit entries with the chain segment around them, the seals and signed approvals they name, the public keys, a readable summary, and a manifest of every file by SHA-256, signed with Ed25519. `kosa8 evidence verify` checks one with nothing but the package — no daemon, no kosa8 state, no network. See docs/design/evidence.md. ## Subcommands - [`kosa8 evidence export`](https://docs.kosa8.com/reference/evidence-export.md): Write a signed evidence package for a sandbox, snapshot, approval or time range - [`kosa8 evidence verify`](https://docs.kosa8.com/reference/evidence-verify.md): Check an evidence package using nothing but the package --- Source: https://docs.kosa8.com/reference/evidence/ # kosa8 evidence export Write a signed evidence package for a sandbox, snapshot, approval or time range Selects the audit entries that name a --sandbox, --snapshot or --approval (any of them), narrowed by --since/--until or --from/--to, or selects a time or sequence range on its own. The package carries every entry from the first selected to the last — the chain cannot be checked across a gap — plus the seals and signed approvals those entries name. --out ending in .tar writes a tar; anything else, a directory. Signed with --signer-key (one line of base64 Ed25519, as `kosa8 policy keygen` writes), or this machine's own key, which attests "this machine exported it" rather than a person's signature. Reads the local state directly, so it works with the daemon stopped. ## Flags | Flag | Type | What it does | Default | |---|---|---|---| | `--ai-bom` | bool | include a CycloneDX AI bill of materials for each sandbox, session and model in scope (on by default with --session or --model) | | | `--approval` | stringArray | entries about this approval request id, full or 8+ characters (repeatable) | `[]` | | `--from` | int64 | only entries from this sequence number | `0` | | `--model` | stringArray | entries naming this model, by sha256: or local name (repeatable) | `[]` | | `--no-compliance` | bool | leave out the machine's doctor --compliance report | | | `--out, -o` | string | package path: .tar for a tar, else a directory (default kosa8-evidence-