kosa8 docs

Guides / JavaScript & TypeScript / Network and secrets

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. This page is about driving them from code.

The rules

egressWhat 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, which keeps the egress it was taken with, or a host folder mount.

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 <broker url>/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

FieldMeaning
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 <broker url>/<name>/…, and the broker forwards to upstream with header set from secret.
result broker.urlThe broker as the sandbox sees it. Use it as HTTPS_PROXY, and as the base URL of each route.
result broker.placeholdersRoute 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=<broker url>/anthropic and ANTHROPIC_API_KEY=<placeholder>, as in the worked example on For developers. That route was not run for this page; the httpbin route above was. Keep API keys out 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: run, exec, streaming output, stdin and time limits.